2* @description MeshCentral plugin module
10/*xjslint plusplus: true */
11/*xjslint maxlen: 256 */
13/*jshint strict: false */
14/*jshint esversion: 6 */
19https://raw.githubusercontent.com/ryanblenis/MeshCentral-Sample/master/config.json
20https://raw.githubusercontent.com/ryanblenis/MeshCentral-DevTools/master/config.json
24module.exports.pluginHandler = function (parent) {
27 obj.fs = require('fs');
28 obj.path = require('path');
29 obj.common = require('./common.js');
31 obj.pluginPath = obj.parent.path.join(obj.parent.datapath, 'plugins');
34 obj.loadList = obj.parent.config.settings.plugins.list; // For local development / manual install, not from DB
36 if (typeof obj.loadList != 'object') {
38 parent.db.getPlugins(function (err, plugins) {
39 plugins.forEach(function (plugin) {
40 if (plugin.status != 1) return;
41 if (obj.fs.existsSync(obj.pluginPath + '/' + plugin.shortName)) {
43 obj.plugins[plugin.shortName] = require(obj.pluginPath + '/' + plugin.shortName + '/' + plugin.shortName + '.js')[plugin.shortName](obj);
44 obj.exports[plugin.shortName] = obj.plugins[plugin.shortName].exports;
46 console.log("Error loading plugin: " + plugin.shortName + " (" + e + "). It has been disabled.", e.stack);
48 try { // try loading local info about plugin to database (if it changed locally)
49 var plugin_config = obj.fs.readFileSync(obj.pluginPath + '/' + plugin.shortName + '/config.json');
50 plugin_config = JSON.parse(plugin_config);
51 parent.db.updatePlugin(plugin._id, plugin_config);
52 } catch (e) { console.log("Plugin config file for " + plugin.name + " could not be parsed."); }
55 obj.parent.updateMeshCore(); // db calls are async, lets inject here once we're ready
58 obj.loadList.forEach(function (plugin, index) {
59 if (obj.fs.existsSync(obj.pluginPath + '/' + plugin)) {
61 obj.plugins[plugin] = require(obj.pluginPath + '/' + plugin + '/' + plugin + '.js')[plugin](obj);
62 obj.exports[plugin] = obj.plugins[plugin].exports;
64 console.log("Error loading plugin: " + plugin + " (" + e + "). It has been disabled.", e.stack);
70 obj.prepExports = function () {
71 var str = 'function() {\r\n';
72 str += ' var obj = {};\r\n';
74 for (var p of Object.keys(obj.plugins)) {
75 str += ' obj.' + p + ' = {};\r\n';
76 if (Array.isArray(obj.exports[p])) {
77 for (var l of Object.values(obj.exports[p])) {
78 str += ' obj.' + p + '.' + l + ' = ' + obj.plugins[p][l].toString() + '\r\n';
84 obj.callHook = function(hookName, ...args) {
85 for (const p of Object.keys(obj)) {
86 if (typeof obj[p][hookName] == 'function') {
87 obj[p][hookName].apply(this, args);
91 // accepts a function returning an object or an object with { tabId: "yourTabIdValue", tabTitle: "Your Tab Title" }
92 obj.registerPluginTab = function(pluginRegInfo) {
94 if (typeof pluginRegInfo == 'function') d = pluginRegInfo();
95 else d = pluginRegInfo;
96 if (d.tabId == null || d.tabTitle == null) { return false; }
98 var defaultOn = 'class="on"';
99 if (Q('p19headers').querySelectorAll("span.on").length) defaultOn = '';
100 QA('p19headers', '<span ' + defaultOn + ' id="p19ph-' + d.tabId + '" onclick="return pluginHandler.callPluginPage(\\''+d.tabId+'\\', this);">'+d.tabTitle+'</span>');
101 QA('p19pages', '<div id="' + d.tabId + '"></div>');
103 QV('MainDevPlugins', true);
105 obj.callPluginPage = function(id, el) {
106 var pages = Q('p19pages').querySelectorAll("#p19pages>div");
107 for (const i of pages) { i.style.display = 'none'; }
109 var tabs = Q('p19headers').querySelectorAll("span");
110 for (const i of tabs) { i.classList.remove('on'); }
111 el.classList.add('on');
112 putstore('_curPluginPage', id);
114 obj.addPluginEx = function() {
115 meshserver.send({ action: 'addplugin', url: Q('pluginurlinput').value});
117 obj.addPluginDlg = function() {
118 if (typeof showModal === 'function') {
119 setDialogMode(2, "Plugin Download URL", 3, obj.addPluginEx, '<p><b>WARNING:</b> Downloading plugins may compromise server security. Only download from trusted sources.</p><input type=text id=pluginurlinput style=width:100% placeholder="https://" />');
120 showModal('xxAddAgentModal', 'idx_dlgOkButton', obj.addPluginEx);
121 focusTextBox('pluginurlinput');
123 // Fallback to setDialogMode for default.handlebars
124 setDialogMode(2, "Plugin Download URL", 3, obj.addPluginEx, '<p><b>WARNING:</b> Downloading plugins may compromise server security. Only download from trusted sources.</p><input type=text id=pluginurlinput style=width:100% placeholder="https://" />');
125 focusTextBox('pluginurlinput');
128 obj.refreshPluginHandler = function() {
129 let st = document.createElement('script');
130 st.src = '/pluginHandler.js';
131 document.body.appendChild(st);
137 obj.refreshJS = function (req, res) {
138 // to minimize server reboots when installing new plugins, we call the new data and overwrite the old pluginHandler on the front end
139 res.set('Content-Type', 'text/javascript');
140 res.send('pluginHandlerBuilder = ' + obj.prepExports() + '\r\n' + ' pluginHandler = new pluginHandlerBuilder(); pluginHandler.callHook("onWebUIStartupEnd");');
143 obj.callHook = function (hookName, ...args) {
144 for (var p in obj.plugins) {
145 if (typeof obj.plugins[p][hookName] == 'function') {
147 obj.plugins[p][hookName](...args);
149 console.log("Error occurred while running plugin hook " + p + ':' + hookName, e);
155 obj.addMeshCoreModules = function (modulesAdd) {
156 for (var plugin in obj.plugins) {
157 var moduleDirPath = null;
158 var modulesDir = null;
159 //if (obj.args.minifycore !== false) { try { moduleDirPath = obj.path.join(obj.pluginPath, 'modules_meshcore_min'); modulesDir = obj.fs.readdirSync(moduleDirPath); } catch (e) { } } // Favor minified modules if present.
160 if (modulesDir == null) { try { moduleDirPath = obj.path.join(obj.pluginPath, plugin + '/modules_meshcore'); modulesDir = obj.fs.readdirSync(moduleDirPath); } catch (e) { } } // Use non-minified mofules.
161 if (modulesDir != null) {
162 for (var i in modulesDir) {
163 if (modulesDir[i].toLowerCase().endsWith('.js')) {
164 var moduleName = modulesDir[i].substring(0, modulesDir[i].length - 3);
165 if (moduleName.endsWith('.min')) { moduleName = moduleName.substring(0, moduleName.length - 4); } // Remove the ".min" for ".min.js" files.
166 var moduleData = ['try { addModule("', moduleName, '", "', obj.parent.escapeCodeString(obj.fs.readFileSync(obj.path.join(moduleDirPath, modulesDir[i])).toString('binary')), '"); addedModules.push("', moduleName, '"); } catch (e) { }\r\n'];
169 // NOTE: "smbios" module makes some non-AI Linux segfault, only include for IA platforms.
170 if (moduleName.startsWith('amt-') || (moduleName == 'smbios')) {
171 // Add to IA / Intel AMT cores only
172 modulesAdd['windows-amt'].push(...moduleData);
173 modulesAdd['linux-amt'].push(...moduleData);
174 } else if (moduleName.startsWith('win-')) {
175 // Add to Windows cores only
176 modulesAdd['windows-amt'].push(...moduleData);
177 } else if (moduleName.startsWith('linux-')) {
178 // Add to Linux cores only
179 modulesAdd['linux-amt'].push(...moduleData);
180 modulesAdd['linux-noamt'].push(...moduleData);
183 modulesAdd['windows-amt'].push(...moduleData);
184 modulesAdd['linux-amt'].push(...moduleData);
185 modulesAdd['linux-noamt'].push(...moduleData);
188 // Merge this module to recovery modules if needed
189 if (modulesAdd['windows-recovery'] != null) {
190 if ((moduleName == 'win-console') || (moduleName == 'win-message-pump') || (moduleName == 'win-terminal')) {
191 modulesAdd['windows-recovery'].push(...moduleData);
195 // Merge this module to agent recovery modules if needed
196 if (modulesAdd['windows-agentrecovery'] != null) {
197 if ((moduleName == 'win-console') || (moduleName == 'win-message-pump') || (moduleName == 'win-terminal')) {
198 modulesAdd['windows-agentrecovery'].push(...moduleData);
207 obj.deviceViewPanel = function () {
209 for (var p in obj.plugins) {
210 if (typeof obj.plugins[p].on_device_header === "function" && typeof obj.plugins[p].on_device_page === "function") {
213 header: obj.plugins[p].on_device_header(),
214 content: obj.plugins[p].on_device_page()
217 console.log("Error occurred while getting plugin views " + p + ':' + ' (' + e + ')');
224 obj.isValidConfig = function (conf, url) { // check for the required attributes
227 typeof conf.name == 'string'
228 && typeof conf.shortName == 'string'
229 && typeof conf.version == 'string'
230 // && typeof conf.author == 'string'
231 && typeof conf.description == 'string'
232 && typeof conf.hasAdminPanel == 'boolean'
233 && typeof conf.homepage == 'string'
234 && typeof conf.changelogUrl == 'string'
235 && typeof conf.configUrl == 'string'
236 && typeof conf.repository == 'object'
237 && typeof conf.repository.type == 'string'
238 && typeof conf.repository.url == 'string'
239 && typeof conf.meshCentralCompat == 'string'
240 // && conf.configUrl == url // make sure we're loading a plugin from its desired config
243 if (conf.repository.type == 'git') {
244 if (typeof conf.downloadUrl != 'string') isValid = false;
249 // https://raw.githubusercontent.com/ryanblenis/MeshCentral-Sample/master/config.json
250 obj.getPluginConfig = function (configUrl) {
251 return new Promise(function (resolve, reject) {
252 var http = (configUrl.indexOf('https://') >= 0) ? require('https') : require('http');
253 if (configUrl.indexOf('://') === -1) reject("Unable to fetch the config: Bad URL (" + configUrl + ")");
254 var options = require('url').parse(configUrl);
255 if (typeof parent.config.settings.plugins.proxy == 'string') { // Proxy support
256 const HttpsProxyAgent = require('https-proxy-agent');
257 options.agent = new HttpsProxyAgent(require('url').parse(parent.config.settings.plugins.proxy));
259 http.get(options, function (res) {
261 res.on('data', function (chunk) {
264 res.on('end', function () {
265 if (configStr[0] == '{') { // Let's be sure we're JSON
267 var pluginConfig = JSON.parse(configStr);
268 if (Array.isArray(pluginConfig) && pluginConfig.length == 1) pluginConfig = pluginConfig[0];
269 if (obj.isValidConfig(pluginConfig, configUrl)) {
270 resolve(pluginConfig);
272 reject("This does not appear to be a valid plugin configuration.");
274 } catch (e) { reject("Error getting plugin config. Check that you have valid JSON."); }
276 reject("Error getting plugin config. Check that you have valid JSON.");
280 }).on('error', function (e) {
281 reject("Error getting plugin config: " + e.message);
286 // MeshCentral now adheres to semver, drop the -<alpha> off the version number for later versions for comparing plugins prior to this change
287 obj.versionToNumber = function(ver) { var x = ver.split('-'); if (x.length != 2) return ver; return x[0]; }
289 // Check if the current version of MeshCentral is at least the minimal required.
290 obj.versionCompare = function(current, minimal) {
291 if (minimal.startsWith('>=')) { minimal = minimal.substring(2); }
292 var c = obj.versionToNumber(current).split('.'), m = obj.versionToNumber(minimal).split('.');
293 if (c.length != m.length) return false;
294 for (var i = 0; i < c.length; i++) { var cx = parseInt(c[i]), cm = parseInt(m[i]); if (cx > cm) { return true; } if (cx < cm) { return false; } }
298 obj.versionGreater = function(a, b) {
299 a = obj.versionToNumber(String(a).replace(/^v/, ''));
300 b = obj.versionToNumber(String(b).replace(/^v/, ''));
301 const partsA = a.split('.').map(Number);
302 const partsB = b.split('.').map(Number);
304 for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
305 const numA = partsA[i] || 0;
306 const numB = partsB[i] || 0;
307 if (numA > numB) return true;
308 if (numA < numB) return false;
313 obj.versionLower = function(a, b) {
314 a = obj.versionToNumber(String(a).replace(/^v/, ''));
315 b = obj.versionToNumber(String(b).replace(/^v/, ''));
316 const partsA = a.split('.').map(Number);
317 const partsB = b.split('.').map(Number);
319 for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
320 const numA = partsA[i] || 0;
321 const numB = partsB[i] || 0;
322 if (numA < numB) return true;
323 if (numA > numB) return false;
328 obj.getPluginLatest = function () {
329 return new Promise(function (resolve, reject) {
330 parent.db.getPlugins(function (err, plugins) {
332 plugins.forEach(function (curconf) {
333 proms.push(obj.getPluginConfig(curconf.configUrl).catch(e => { return null; }));
336 Promise.all(proms).then(function (newconfs) {
338 // Filter out config download issues
339 newconfs.forEach(function (nc) { if (nc !== null) nconfs.push(nc); });
340 if (nconfs.length == 0) { resolve([]); } else {
341 nconfs.forEach(function (newconf) {
343 plugins.forEach(function (conf) {
344 if (conf.configUrl == newconf.configUrl) curconf = conf;
346 if (curconf == null) reject("Some plugin configs could not be parsed");
349 'installedVersion': curconf.version,
350 'version': newconf.version,
351 'hasUpdate': obj.versionGreater(newconf.version, curconf.version),
352 'meshCentralCompat': obj.versionCompare(parent.currentVer, newconf.meshCentralCompat),
353 'changelogUrl': curconf.changelogUrl,
354 'status': curconf.status
359 }).catch((e) => { console.log("Error reaching plugins, update call aborted.", e) });
364 obj.addPlugin = function (pluginConfig) {
365 return new Promise(function (resolve, reject) {
366 parent.db.addPlugin({
367 'name': pluginConfig.name,
368 'shortName': pluginConfig.shortName,
369 'version': pluginConfig.version,
370 'description': pluginConfig.description,
371 'hasAdminPanel': pluginConfig.hasAdminPanel,
372 'homepage': pluginConfig.homepage,
373 'changelogUrl': pluginConfig.changelogUrl,
374 'configUrl': pluginConfig.configUrl,
375 'downloadUrl': pluginConfig.downloadUrl,
377 'type': pluginConfig.repository.type,
378 'url': pluginConfig.repository.url
380 'meshCentralCompat': pluginConfig.meshCentralCompat,
381 'versionHistoryUrl': pluginConfig.versionHistoryUrl,
382 'status': 0 // 0: disabled, 1: enabled
384 parent.db.getPlugins(function (err, docs) {
385 if (err) reject(err);
392 obj.installPlugin = function (id, version_only, force_url, func) {
393 parent.db.getPlugin(id, function (err, docs) {
394 // the "id" would probably suffice, but is probably an sanitary issue, generate a random instead
395 var randId = Math.random().toString(32).replace('0.', '');
396 var tmpDir = require('os').tmpdir();
397 var fileName = obj.parent.path.join(tmpDir, 'Plugin_' + randId + '.zip');
399 obj.fs.accessSync(tmpDir, obj.fs.constants.W_OK);
401 var pluginTmpPath = obj.parent.path.join(obj.pluginPath, '_tmp');
402 if (!obj.fs.existsSync(pluginTmpPath)) {
403 obj.fs.mkdirSync(pluginTmpPath, { recursive: true });
405 fileName = obj.parent.path.join(pluginTmpPath, 'Plugin_' + randId + '.zip');
407 var plugin = docs[0];
408 if (plugin.repository.type == 'git') {
411 file = obj.fs.createWriteStream(fileName);
413 if (fileName.indexOf(tmpDir) >= 0) {
414 var pluginTmpPath = obj.parent.path.join(obj.pluginPath, '_tmp');
415 if (!obj.fs.existsSync(pluginTmpPath)) {
416 obj.fs.mkdirSync(pluginTmpPath, { recursive: true });
418 fileName = obj.parent.path.join(pluginTmpPath, 'Plugin_' + randId + '.zip');
419 file = obj.fs.createWriteStream(fileName);
424 var dl_url = plugin.downloadUrl;
425 if (version_only != null && version_only != false) dl_url = version_only.url;
426 if (force_url != null) dl_url = force_url;
427 var url = require('url');
428 var q = url.parse(dl_url, true);
429 var http = (q.protocol == "http:") ? require('http') : require('https');
435 'User-Agent': 'MeshCentral'
437 followRedirects: true,
440 if (typeof parent.config.settings.plugins.proxy == 'string') { // Proxy support
441 const HttpsProxyAgent = require('https-proxy-agent');
442 opts.agent = new HttpsProxyAgent(require('url').parse(parent.config.settings.plugins.proxy));
444 var request = http.get(opts, function (response) {
445 // handle redirections with grace
446 if (response.headers.location) {
447 file.close(() => obj.fs.unlink(fileName, () => {}));
448 return obj.installPlugin(id, version_only, response.headers.location, func);
451 file.on('finish', function () {
452 file.close(function () {
453 var yauzl = require('yauzl');
454 if (!obj.fs.existsSync(obj.pluginPath)) {
455 obj.fs.mkdirSync(obj.pluginPath);
457 if (!obj.fs.existsSync(obj.parent.path.join(obj.pluginPath, plugin.shortName))) {
458 obj.fs.mkdirSync(obj.parent.path.join(obj.pluginPath, plugin.shortName));
460 yauzl.open(fileName, { lazyEntries: true }, function (err, zipfile) {
463 zipfile.on('entry', function (entry) {
464 let pluginPath = obj.parent.path.join(obj.pluginPath, plugin.shortName);
465 let pathReg = new RegExp(/(.*?\/)/);
466 //if (process.platform == 'win32') { pathReg = new RegExp(/(.*?\\/); }
467 let filePath = obj.parent.path.join(pluginPath, entry.fileName.replace(pathReg, '')); // remove top level dir
469 if (/\/$/.test(entry.fileName)) { // dir
470 if (!obj.fs.existsSync(filePath))
471 obj.fs.mkdirSync(filePath);
474 zipfile.openReadStream(entry, function (err, readStream) {
476 readStream.on('end', function () { zipfile.readEntry(); });
477 if (process.platform == 'win32') {
478 readStream.pipe(obj.fs.createWriteStream(filePath));
480 var fileMode = (entry.externalFileAttributes >> 16) & 0x0fff;
481 if( fileMode <= 0 ) fileMode = 0o644;
482 readStream.pipe(obj.fs.createWriteStream(filePath, { mode: fileMode }));
487 zipfile.on('end', function () {
488 setTimeout(function () {
489 obj.fs.unlinkSync(fileName);
490 if (version_only == null || version_only === false) {
491 parent.db.setPluginStatus(id, 1, func);
493 parent.db.updatePlugin(id, { status: 1, version: version_only.name }, func);
496 obj.plugins[plugin.shortName] = require(obj.pluginPath + '/' + plugin.shortName + '/' + plugin.shortName + '.js')[plugin.shortName](obj);
497 obj.exports[plugin.shortName] = obj.plugins[plugin.shortName].exports;
498 if (typeof obj.plugins[plugin.shortName].server_startup == 'function') obj.plugins[plugin.shortName].server_startup();
499 } catch (e) { console.log('Error instantiating new plugin: ', e); }
501 var plugin_config = obj.fs.readFileSync(obj.pluginPath + '/' + plugin.shortName + '/config.json');
502 plugin_config = JSON.parse(plugin_config);
503 parent.db.updatePlugin(plugin._id, plugin_config);
504 } catch (e) { console.log('Error reading plugin config upon install'); }
505 parent.updateMeshCore();
512 } else if (plugin.repository.type == 'npm') {
513 // @TODO npm support? (need a test plugin)
518 obj.getPluginVersions = function (id) {
519 return new Promise(function (resolve, reject) {
520 parent.db.getPlugin(id, function (err, docs) {
521 var plugin = docs[0];
522 if (plugin.versionHistoryUrl == null) reject("No version history available for this plugin.");
523 var url = require('url');
524 var q = url.parse(plugin.versionHistoryUrl, true);
525 var http = (q.protocol == 'http:') ? require('http') : require('https');
531 'User-Agent': 'MeshCentral',
532 'Accept': 'application/vnd.github.v3+json'
535 if (typeof parent.config.settings.plugins.proxy == 'string') { // Proxy support
536 const HttpsProxyAgent = require('https-proxy-agent');
537 options.agent = new HttpsProxyAgent(require('url').parse(parent.config.settings.plugins.proxy));
539 http.get(opts, function (res) {
541 res.on('data', function (chunk) {
544 res.on('end', function () {
545 if ((versStr[0] == '{') || (versStr[0] == '[')) { // let's be sure we're JSON
547 var vers = JSON.parse(versStr);
549 vers.forEach((v) => {
550 if (obj.versionLower(v.name, plugin.version)) vList.push(v);
552 if (vers.length == 0) reject("No previous versions available.");
553 resolve({ 'id': plugin._id, 'name': plugin.name, versionList: vList });
554 } catch (e) { reject("Version history problem."); }
556 reject("Version history appears to be malformed." + versStr);
559 }).on('error', function (e) {
560 reject("Error getting plugin versions: " + e.message);
566 obj.disablePlugin = function (id, func) {
567 parent.db.getPlugin(id, function (err, docs) {
568 var plugin = docs[0];
569 parent.db.setPluginStatus(id, 0, func);
570 delete obj.plugins[plugin.shortName];
571 delete obj.exports[plugin.shortName];
572 parent.updateMeshCore();
576 obj.removePlugin = function (id, func) {
577 parent.db.getPlugin(id, function (err, docs) {
578 var plugin = docs[0];
579 let pluginPath = obj.parent.path.join(obj.pluginPath, plugin.shortName);
580 if (obj.fs.existsSync(pluginPath)) {
582 obj.fs.rmSync(pluginPath, { recursive: true, force: true });
584 console.log("Error removing plugin directory:", e);
587 parent.db.deletePlugin(id, func);
588 delete obj.plugins[plugin.shortName];
592 obj.handleAdminReq = function (req, res, user, serv) {
593 if ((req.query.pin == null) || (obj.common.isAlphaNumeric(req.query.pin) !== true)) { res.sendStatus(401); return; }
594 var path = obj.path.join(obj.pluginPath, req.query.pin, 'views');
595 serv.app.set('views', path);
596 if ((obj.plugins[req.query.pin] != null) && (typeof obj.plugins[req.query.pin].handleAdminReq == 'function')) {
597 obj.plugins[req.query.pin].handleAdminReq(req, res, user);
603 obj.handleAdminPostReq = function (req, res, user, serv) {
604 if ((req.query.pin == null) || (obj.common.isAlphaNumeric(req.query.pin) !== true)) { res.sendStatus(401); return; }
605 var path = obj.path.join(obj.pluginPath, req.query.pin, 'views');
606 serv.app.set('views', path);
607 if ((obj.plugins[req.query.pin] != null) && (typeof obj.plugins[req.query.pin].handleAdminPostReq == 'function')) {
608 obj.plugins[req.query.pin].handleAdminPostReq(req, res, user);