Files
MeshCentral/pluginHandler.js
TheDevRyan afe0aed976 Improve plugin file handling and cleanup logic (#7434)
* Improve plugin file handling and cleanup logic

Adds checks and fallbacks for temporary directory write access when creating plugin zip files, and enhances error handling when creating write streams. Updates plugin removal to use fs.rmSync with force and recursive options, and adds error handling for directory removal.

* Update pluginHandler.js
2025-11-17 10:33:09 +00:00

606 lines
30 KiB
JavaScript

/**
* @description MeshCentral plugin module
* @author Ryan Blenis
* @copyright
* @license Apache-2.0
* @version v0.0.1
*/
/*xjslint node: true */
/*xjslint plusplus: true */
/*xjslint maxlen: 256 */
/*jshint node: true */
/*jshint strict: false */
/*jshint esversion: 6 */
"use strict";
/*
Existing plugins:
https://raw.githubusercontent.com/ryanblenis/MeshCentral-Sample/master/config.json
https://raw.githubusercontent.com/ryanblenis/MeshCentral-DevTools/master/config.json
*/
module.exports.pluginHandler = function (parent) {
var obj = {};
obj.fs = require('fs');
obj.path = require('path');
obj.common = require('./common.js');
obj.parent = parent;
obj.pluginPath = obj.parent.path.join(obj.parent.datapath, 'plugins');
obj.plugins = {};
obj.exports = {};
obj.loadList = obj.parent.config.settings.plugins.list; // For local development / manual install, not from DB
if (typeof obj.loadList != 'object') {
obj.loadList = {};
parent.db.getPlugins(function (err, plugins) {
plugins.forEach(function (plugin) {
if (plugin.status != 1) return;
if (obj.fs.existsSync(obj.pluginPath + '/' + plugin.shortName)) {
try {
obj.plugins[plugin.shortName] = require(obj.pluginPath + '/' + plugin.shortName + '/' + plugin.shortName + '.js')[plugin.shortName](obj);
obj.exports[plugin.shortName] = obj.plugins[plugin.shortName].exports;
} catch (e) {
console.log("Error loading plugin: " + plugin.shortName + " (" + e + "). It has been disabled.", e.stack);
}
try { // try loading local info about plugin to database (if it changed locally)
var plugin_config = obj.fs.readFileSync(obj.pluginPath + '/' + plugin.shortName + '/config.json');
plugin_config = JSON.parse(plugin_config);
parent.db.updatePlugin(plugin._id, plugin_config);
} catch (e) { console.log("Plugin config file for " + plugin.name + " could not be parsed."); }
}
});
obj.parent.updateMeshCore(); // db calls are async, lets inject here once we're ready
});
} else {
obj.loadList.forEach(function (plugin, index) {
if (obj.fs.existsSync(obj.pluginPath + '/' + plugin)) {
try {
obj.plugins[plugin] = require(obj.pluginPath + '/' + plugin + '/' + plugin + '.js')[plugin](obj);
obj.exports[plugin] = obj.plugins[plugin].exports;
} catch (e) {
console.log("Error loading plugin: " + plugin + " (" + e + "). It has been disabled.", e.stack);
}
}
});
}
obj.prepExports = function () {
var str = 'function() {\r\n';
str += ' var obj = {};\r\n';
for (var p of Object.keys(obj.plugins)) {
str += ' obj.' + p + ' = {};\r\n';
if (Array.isArray(obj.exports[p])) {
for (var l of Object.values(obj.exports[p])) {
str += ' obj.' + p + '.' + l + ' = ' + obj.plugins[p][l].toString() + '\r\n';
}
}
}
str += `
obj.callHook = function(hookName, ...args) {
for (const p of Object.keys(obj)) {
if (typeof obj[p][hookName] == 'function') {
obj[p][hookName].apply(this, args);
}
}
};
// accepts a function returning an object or an object with { tabId: "yourTabIdValue", tabTitle: "Your Tab Title" }
obj.registerPluginTab = function(pluginRegInfo) {
var d = null;
if (typeof pluginRegInfo == 'function') d = pluginRegInfo();
else d = pluginRegInfo;
if (d.tabId == null || d.tabTitle == null) { return false; }
if (!Q(d.tabId)) {
var defaultOn = 'class="on"';
if (Q('p19headers').querySelectorAll("span.on").length) defaultOn = '';
QA('p19headers', '<span ' + defaultOn + ' id="p19ph-' + d.tabId + '" onclick="return pluginHandler.callPluginPage(\\''+d.tabId+'\\', this);">'+d.tabTitle+'</span>');
QA('p19pages', '<div id="' + d.tabId + '"></div>');
}
QV('MainDevPlugins', true);
};
obj.callPluginPage = function(id, el) {
var pages = Q('p19pages').querySelectorAll("#p19pages>div");
for (const i of pages) { i.style.display = 'none'; }
QV(id, true);
var tabs = Q('p19headers').querySelectorAll("span");
for (const i of tabs) { i.classList.remove('on'); }
el.classList.add('on');
putstore('_curPluginPage', id);
};
obj.addPluginEx = function() {
meshserver.send({ action: 'addplugin', url: Q('pluginurlinput').value});
};
obj.addPluginDlg = function() {
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://" />');
focusTextBox('pluginurlinput');
};
obj.refreshPluginHandler = function() {
let st = document.createElement('script');
st.src = '/pluginHandler.js';
document.body.appendChild(st);
};
return obj; }`;
return str;
}
obj.refreshJS = function (req, res) {
// to minimize server reboots when installing new plugins, we call the new data and overwrite the old pluginHandler on the front end
res.set('Content-Type', 'text/javascript');
res.send('pluginHandlerBuilder = ' + obj.prepExports() + '\r\n' + ' pluginHandler = new pluginHandlerBuilder(); pluginHandler.callHook("onWebUIStartupEnd");');
}
obj.callHook = function (hookName, ...args) {
for (var p in obj.plugins) {
if (typeof obj.plugins[p][hookName] == 'function') {
try {
obj.plugins[p][hookName](...args);
} catch (e) {
console.log("Error occurred while running plugin hook " + p + ':' + hookName, e);
}
}
}
};
obj.addMeshCoreModules = function (modulesAdd) {
for (var plugin in obj.plugins) {
var moduleDirPath = null;
var modulesDir = null;
//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.
if (modulesDir == null) { try { moduleDirPath = obj.path.join(obj.pluginPath, plugin + '/modules_meshcore'); modulesDir = obj.fs.readdirSync(moduleDirPath); } catch (e) { } } // Use non-minified mofules.
if (modulesDir != null) {
for (var i in modulesDir) {
if (modulesDir[i].toLowerCase().endsWith('.js')) {
var moduleName = modulesDir[i].substring(0, modulesDir[i].length - 3);
if (moduleName.endsWith('.min')) { moduleName = moduleName.substring(0, moduleName.length - 4); } // Remove the ".min" for ".min.js" files.
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'];
// Merge this module
// NOTE: "smbios" module makes some non-AI Linux segfault, only include for IA platforms.
if (moduleName.startsWith('amt-') || (moduleName == 'smbios')) {
// Add to IA / Intel AMT cores only
modulesAdd['windows-amt'].push(...moduleData);
modulesAdd['linux-amt'].push(...moduleData);
} else if (moduleName.startsWith('win-')) {
// Add to Windows cores only
modulesAdd['windows-amt'].push(...moduleData);
} else if (moduleName.startsWith('linux-')) {
// Add to Linux cores only
modulesAdd['linux-amt'].push(...moduleData);
modulesAdd['linux-noamt'].push(...moduleData);
} else {
// Add to all cores
modulesAdd['windows-amt'].push(...moduleData);
modulesAdd['linux-amt'].push(...moduleData);
modulesAdd['linux-noamt'].push(...moduleData);
}
// Merge this module to recovery modules if needed
if (modulesAdd['windows-recovery'] != null) {
if ((moduleName == 'win-console') || (moduleName == 'win-message-pump') || (moduleName == 'win-terminal')) {
modulesAdd['windows-recovery'].push(...moduleData);
}
}
// Merge this module to agent recovery modules if needed
if (modulesAdd['windows-agentrecovery'] != null) {
if ((moduleName == 'win-console') || (moduleName == 'win-message-pump') || (moduleName == 'win-terminal')) {
modulesAdd['windows-agentrecovery'].push(...moduleData);
}
}
}
}
}
}
};
obj.deviceViewPanel = function () {
var panel = {};
for (var p in obj.plugins) {
if (typeof obj.plugins[p][hookName] == 'function') {
try {
panel[p].header = obj.plugins[p].on_device_header();
panel[p].content = obj.plugins[p].on_device_page();
} catch (e) {
console.log("Error occurred while getting plugin views " + p + ':' + ' (' + e + ')');
}
}
}
return panel;
};
obj.isValidConfig = function (conf, url) { // check for the required attributes
var isValid = true;
if (!(
typeof conf.name == 'string'
&& typeof conf.shortName == 'string'
&& typeof conf.version == 'string'
// && typeof conf.author == 'string'
&& typeof conf.description == 'string'
&& typeof conf.hasAdminPanel == 'boolean'
&& typeof conf.homepage == 'string'
&& typeof conf.changelogUrl == 'string'
&& typeof conf.configUrl == 'string'
&& typeof conf.repository == 'object'
&& typeof conf.repository.type == 'string'
&& typeof conf.repository.url == 'string'
&& typeof conf.meshCentralCompat == 'string'
// && conf.configUrl == url // make sure we're loading a plugin from its desired config
)) isValid = false;
// more checks here?
if (conf.repository.type == 'git') {
if (typeof conf.downloadUrl != 'string') isValid = false;
}
return isValid;
};
// https://raw.githubusercontent.com/ryanblenis/MeshCentral-Sample/master/config.json
obj.getPluginConfig = function (configUrl) {
return new Promise(function (resolve, reject) {
var http = (configUrl.indexOf('https://') >= 0) ? require('https') : require('http');
if (configUrl.indexOf('://') === -1) reject("Unable to fetch the config: Bad URL (" + configUrl + ")");
var options = require('url').parse(configUrl);
if (typeof parent.config.settings.plugins.proxy == 'string') { // Proxy support
const HttpsProxyAgent = require('https-proxy-agent');
options.agent = new HttpsProxyAgent(require('url').parse(parent.config.settings.plugins.proxy));
}
http.get(options, function (res) {
var configStr = '';
res.on('data', function (chunk) {
configStr += chunk;
});
res.on('end', function () {
if (configStr[0] == '{') { // Let's be sure we're JSON
try {
var pluginConfig = JSON.parse(configStr);
if (Array.isArray(pluginConfig) && pluginConfig.length == 1) pluginConfig = pluginConfig[0];
if (obj.isValidConfig(pluginConfig, configUrl)) {
resolve(pluginConfig);
} else {
reject("This does not appear to be a valid plugin configuration.");
}
} catch (e) { reject("Error getting plugin config. Check that you have valid JSON."); }
} else {
reject("Error getting plugin config. Check that you have valid JSON.");
}
});
}).on('error', function (e) {
reject("Error getting plugin config: " + e.message);
});
})
};
// MeshCentral now adheres to semver, drop the -<alpha> off the version number for later versions for comparing plugins prior to this change
obj.versionToNumber = function(ver) { var x = ver.split('-'); if (x.length != 2) return ver; return x[0]; }
// Check if the current version of MeshCentral is at least the minimal required.
obj.versionCompare = function(current, minimal) {
if (minimal.startsWith('>=')) { minimal = minimal.substring(2); }
var c = obj.versionToNumber(current).split('.'), m = obj.versionToNumber(minimal).split('.');
if (c.length != m.length) return false;
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; } }
return true;
}
obj.versionGreater = function(a, b) {
a = obj.versionToNumber(String(a).replace(/^v/, ''));
b = obj.versionToNumber(String(b).replace(/^v/, ''));
const partsA = a.split('.').map(Number);
const partsB = b.split('.').map(Number);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const numA = partsA[i] || 0;
const numB = partsB[i] || 0;
if (numA > numB) return true;
if (numA < numB) return false;
}
return false;
};
obj.versionLower = function(a, b) {
a = obj.versionToNumber(String(a).replace(/^v/, ''));
b = obj.versionToNumber(String(b).replace(/^v/, ''));
const partsA = a.split('.').map(Number);
const partsB = b.split('.').map(Number);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const numA = partsA[i] || 0;
const numB = partsB[i] || 0;
if (numA < numB) return true;
if (numA > numB) return false;
}
return false;
};
obj.getPluginLatest = function () {
return new Promise(function (resolve, reject) {
parent.db.getPlugins(function (err, plugins) {
var proms = [];
plugins.forEach(function (curconf) {
proms.push(obj.getPluginConfig(curconf.configUrl).catch(e => { return null; }));
});
var latestRet = [];
Promise.all(proms).then(function (newconfs) {
var nconfs = [];
// Filter out config download issues
newconfs.forEach(function (nc) { if (nc !== null) nconfs.push(nc); });
if (nconfs.length == 0) { resolve([]); } else {
nconfs.forEach(function (newconf) {
var curconf = null;
plugins.forEach(function (conf) {
if (conf.configUrl == newconf.configUrl) curconf = conf;
});
if (curconf == null) reject("Some plugin configs could not be parsed");
latestRet.push({
'id': curconf._id,
'installedVersion': curconf.version,
'version': newconf.version,
'hasUpdate': obj.versionGreater(newconf.version, curconf.version),
'meshCentralCompat': obj.versionCompare(parent.currentVer, newconf.meshCentralCompat),
'changelogUrl': curconf.changelogUrl,
'status': curconf.status
});
resolve(latestRet);
});
}
}).catch((e) => { console.log("Error reaching plugins, update call aborted.", e) });
});
});
};
obj.addPlugin = function (pluginConfig) {
return new Promise(function (resolve, reject) {
parent.db.addPlugin({
'name': pluginConfig.name,
'shortName': pluginConfig.shortName,
'version': pluginConfig.version,
'description': pluginConfig.description,
'hasAdminPanel': pluginConfig.hasAdminPanel,
'homepage': pluginConfig.homepage,
'changelogUrl': pluginConfig.changelogUrl,
'configUrl': pluginConfig.configUrl,
'downloadUrl': pluginConfig.downloadUrl,
'repository': {
'type': pluginConfig.repository.type,
'url': pluginConfig.repository.url
},
'meshCentralCompat': pluginConfig.meshCentralCompat,
'versionHistoryUrl': pluginConfig.versionHistoryUrl,
'status': 0 // 0: disabled, 1: enabled
}, function () {
parent.db.getPlugins(function (err, docs) {
if (err) reject(err);
else resolve(docs);
});
});
});
};
obj.installPlugin = function (id, version_only, force_url, func) {
parent.db.getPlugin(id, function (err, docs) {
// the "id" would probably suffice, but is probably an sanitary issue, generate a random instead
var randId = Math.random().toString(32).replace('0.', '');
var tmpDir = require('os').tmpdir();
var fileName = obj.parent.path.join(tmpDir, 'Plugin_' + randId + '.zip');
try {
obj.fs.accessSync(tmpDir, obj.fs.constants.W_OK);
} catch (e) {
var pluginTmpPath = obj.parent.path.join(obj.pluginPath, '_tmp');
if (!obj.fs.existsSync(pluginTmpPath)) {
obj.fs.mkdirSync(pluginTmpPath, { recursive: true });
}
fileName = obj.parent.path.join(pluginTmpPath, 'Plugin_' + randId + '.zip');
}
var plugin = docs[0];
if (plugin.repository.type == 'git') {
var file;
try {
file = obj.fs.createWriteStream(fileName);
} catch (e) {
if (fileName.indexOf(tmpDir) >= 0) {
var pluginTmpPath = obj.parent.path.join(obj.pluginPath, '_tmp');
if (!obj.fs.existsSync(pluginTmpPath)) {
obj.fs.mkdirSync(pluginTmpPath, { recursive: true });
}
fileName = obj.parent.path.join(pluginTmpPath, 'Plugin_' + randId + '.zip');
file = obj.fs.createWriteStream(fileName);
} else {
throw e;
}
}
var dl_url = plugin.downloadUrl;
if (version_only != null && version_only != false) dl_url = version_only.url;
if (force_url != null) dl_url = force_url;
var url = require('url');
var q = url.parse(dl_url, true);
var http = (q.protocol == "http:") ? require('http') : require('https');
var opts = {
path: q.pathname,
host: q.hostname,
port: q.port,
headers: {
'User-Agent': 'MeshCentral'
},
followRedirects: true,
method: 'GET'
};
if (typeof parent.config.settings.plugins.proxy == 'string') { // Proxy support
const HttpsProxyAgent = require('https-proxy-agent');
opts.agent = new HttpsProxyAgent(require('url').parse(parent.config.settings.plugins.proxy));
}
var request = http.get(opts, function (response) {
// handle redirections with grace
if (response.headers.location) {
file.close(() => obj.fs.unlink(fileName, () => {}));
return obj.installPlugin(id, version_only, response.headers.location, func);
}
response.pipe(file);
file.on('finish', function () {
file.close(function () {
var yauzl = require('yauzl');
if (!obj.fs.existsSync(obj.pluginPath)) {
obj.fs.mkdirSync(obj.pluginPath);
}
if (!obj.fs.existsSync(obj.parent.path.join(obj.pluginPath, plugin.shortName))) {
obj.fs.mkdirSync(obj.parent.path.join(obj.pluginPath, plugin.shortName));
}
yauzl.open(fileName, { lazyEntries: true }, function (err, zipfile) {
if (err) throw err;
zipfile.readEntry();
zipfile.on('entry', function (entry) {
let pluginPath = obj.parent.path.join(obj.pluginPath, plugin.shortName);
let pathReg = new RegExp(/(.*?\/)/);
//if (process.platform == 'win32') { pathReg = new RegExp(/(.*?\\/); }
let filePath = obj.parent.path.join(pluginPath, entry.fileName.replace(pathReg, '')); // remove top level dir
if (/\/$/.test(entry.fileName)) { // dir
if (!obj.fs.existsSync(filePath))
obj.fs.mkdirSync(filePath);
zipfile.readEntry();
} else { // file
zipfile.openReadStream(entry, function (err, readStream) {
if (err) throw err;
readStream.on('end', function () { zipfile.readEntry(); });
if (process.platform == 'win32') {
readStream.pipe(obj.fs.createWriteStream(filePath));
} else {
var fileMode = (entry.externalFileAttributes >> 16) & 0x0fff;
if( fileMode <= 0 ) fileMode = 0o644;
readStream.pipe(obj.fs.createWriteStream(filePath, { mode: fileMode }));
}
});
}
});
zipfile.on('end', function () {
setTimeout(function () {
obj.fs.unlinkSync(fileName);
if (version_only == null || version_only === false) {
parent.db.setPluginStatus(id, 1, func);
} else {
parent.db.updatePlugin(id, { status: 1, version: version_only.name }, func);
}
try {
obj.plugins[plugin.shortName] = require(obj.pluginPath + '/' + plugin.shortName + '/' + plugin.shortName + '.js')[plugin.shortName](obj);
obj.exports[plugin.shortName] = obj.plugins[plugin.shortName].exports;
if (typeof obj.plugins[plugin.shortName].server_startup == 'function') obj.plugins[plugin.shortName].server_startup();
} catch (e) { console.log('Error instantiating new plugin: ', e); }
try {
var plugin_config = obj.fs.readFileSync(obj.pluginPath + '/' + plugin.shortName + '/config.json');
plugin_config = JSON.parse(plugin_config);
parent.db.updatePlugin(plugin._id, plugin_config);
} catch (e) { console.log('Error reading plugin config upon install'); }
parent.updateMeshCore();
});
});
});
});
});
});
} else if (plugin.repository.type == 'npm') {
// @TODO npm support? (need a test plugin)
}
});
};
obj.getPluginVersions = function (id) {
return new Promise(function (resolve, reject) {
parent.db.getPlugin(id, function (err, docs) {
var plugin = docs[0];
if (plugin.versionHistoryUrl == null) reject("No version history available for this plugin.");
var url = require('url');
var q = url.parse(plugin.versionHistoryUrl, true);
var http = (q.protocol == 'http:') ? require('http') : require('https');
var opts = {
path: q.pathname,
host: q.hostname,
port: q.port,
headers: {
'User-Agent': 'MeshCentral',
'Accept': 'application/vnd.github.v3+json'
}
};
if (typeof parent.config.settings.plugins.proxy == 'string') { // Proxy support
const HttpsProxyAgent = require('https-proxy-agent');
options.agent = new HttpsProxyAgent(require('url').parse(parent.config.settings.plugins.proxy));
}
http.get(opts, function (res) {
var versStr = '';
res.on('data', function (chunk) {
versStr += chunk;
});
res.on('end', function () {
if ((versStr[0] == '{') || (versStr[0] == '[')) { // let's be sure we're JSON
try {
var vers = JSON.parse(versStr);
var vList = [];
vers.forEach((v) => {
if (obj.versionLower(v.name, plugin.version)) vList.push(v);
});
if (vers.length == 0) reject("No previous versions available.");
resolve({ 'id': plugin._id, 'name': plugin.name, versionList: vList });
} catch (e) { reject("Version history problem."); }
} else {
reject("Version history appears to be malformed." + versStr);
}
});
}).on('error', function (e) {
reject("Error getting plugin versions: " + e.message);
});
});
});
};
obj.disablePlugin = function (id, func) {
parent.db.getPlugin(id, function (err, docs) {
var plugin = docs[0];
parent.db.setPluginStatus(id, 0, func);
delete obj.plugins[plugin.shortName];
delete obj.exports[plugin.shortName];
parent.updateMeshCore();
});
};
obj.removePlugin = function (id, func) {
parent.db.getPlugin(id, function (err, docs) {
var plugin = docs[0];
let pluginPath = obj.parent.path.join(obj.pluginPath, plugin.shortName);
if (obj.fs.existsSync(pluginPath)) {
try {
obj.fs.rmSync(pluginPath, { recursive: true, force: true });
} catch (e) {
console.log("Error removing plugin directory:", e);
}
}
parent.db.deletePlugin(id, func);
delete obj.plugins[plugin.shortName];
});
};
obj.handleAdminReq = function (req, res, user, serv) {
if ((req.query.pin == null) || (obj.common.isAlphaNumeric(req.query.pin) !== true)) { res.sendStatus(401); return; }
var path = obj.path.join(obj.pluginPath, req.query.pin, 'views');
serv.app.set('views', path);
if ((obj.plugins[req.query.pin] != null) && (typeof obj.plugins[req.query.pin].handleAdminReq == 'function')) {
obj.plugins[req.query.pin].handleAdminReq(req, res, user);
} else {
res.sendStatus(401);
}
}
obj.handleAdminPostReq = function (req, res, user, serv) {
if ((req.query.pin == null) || (obj.common.isAlphaNumeric(req.query.pin) !== true)) { res.sendStatus(401); return; }
var path = obj.path.join(obj.pluginPath, req.query.pin, 'views');
serv.app.set('views', path);
if ((obj.plugins[req.query.pin] != null) && (typeof obj.plugins[req.query.pin].handleAdminPostReq == 'function')) {
obj.plugins[req.query.pin].handleAdminPostReq(req, res, user);
} else {
res.sendStatus(401);
}
}
return obj;
};