master
js 957 lines 45.7 KB
Raw
1 /**
2 * @description MeshCentral plugin module
3 * @author Ryan Blenis
4 * @copyright
5 * @license Apache-2.0
6 * @version v0.0.1
7 */
8
9 /*xjslint node: true */
10 /*xjslint plusplus: true */
11 /*xjslint maxlen: 256 */
12 /*jshint node: true */
13 /*jshint strict: false */
14 /*jshint esversion: 6 */
15 "use strict";
16
17 /*
18 Existing plugins:
19 https://raw.githubusercontent.com/ryanblenis/MeshCentral-Sample/master/config.json
20 https://raw.githubusercontent.com/ryanblenis/MeshCentral-DevTools/master/config.json
21 */
22
23
24 module.exports.pluginHandler = function (parent) {
25 var obj = {};
26
27 obj.fs = require('fs');
28 obj.path = require('path');
29 obj.common = require('./common.js');
30 obj.parent = parent;
31 obj.pluginPath = obj.parent.path.join(obj.parent.datapath, 'plugins');
32 obj.plugins = {};
33 obj.exports = {};
34 obj.loadList = obj.parent.config.settings.plugins.list; // For local development / manual install, not from DB
35
36 if (typeof obj.loadList != 'object') {
37 obj.loadList = {};
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)) {
42 try {
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;
45 } catch (e) {
46 console.log("Error loading plugin: " + plugin.shortName + " (" + e + "). It has been disabled.", e.stack);
47 }
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."); }
53 }
54 });
55 obj.parent.updateMeshCore(); // db calls are async, lets inject here once we're ready
56 });
57 } else {
58 obj.loadList.forEach(function (plugin, index) {
59 if (obj.fs.existsSync(obj.pluginPath + '/' + plugin)) {
60 try {
61 obj.plugins[plugin] = require(obj.pluginPath + '/' + plugin + '/' + plugin + '.js')[plugin](obj);
62 obj.exports[plugin] = obj.plugins[plugin].exports;
63 } catch (e) {
64 console.log("Error loading plugin: " + plugin + " (" + e + "). It has been disabled.", e.stack);
65 }
66 }
67 });
68 }
69
70 obj.prepExports = function () {
71 var str = 'function() {\r\n';
72 str += ' var obj = {};\r\n';
73
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';
79 }
80 }
81 }
82
83 str += `
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);
88 }
89 }
90 };
91 // accepts a function returning an object or an object with { tabId: "yourTabIdValue", tabTitle: "Your Tab Title" }
92 obj.registerPluginTab = function(pluginRegInfo) {
93 var d = null;
94 if (typeof pluginRegInfo == 'function') d = pluginRegInfo();
95 else d = pluginRegInfo;
96 if (d.tabId == null || d.tabTitle == null) { return false; }
97 if (!document.getElementById(d.tabId)) {
98 var defaultOn = 'class="on"';
99 if (document.getElementById('p19headers').querySelectorAll("span.on").length) defaultOn = '';
100 document.getElementById('p19headers').innerHTML += '<span ' + defaultOn + ' id="p19ph-' + d.tabId + '" onclick="return pluginHandler.callPluginPage(\\''+d.tabId+'\\', this);">'+d.tabTitle+'</span>';
101 document.getElementById('p19pages').innerHTML += '<div id="' + d.tabId + '"></div>';
102 }
103 document.getElementById('MainDevPlugins').style.display = '';
104 };
105 obj.callPluginPage = function(id, el) {
106 var pages = document.getElementById('p19pages').querySelectorAll("#p19pages>div");
107 for (const i of pages) { i.style.display = 'none'; }
108 document.getElementById(id).style.display = '';
109 var tabs = document.getElementById('p19headers').querySelectorAll("span");
110 for (const i of tabs) { i.classList.remove('on'); }
111 el.classList.add('on');
112 putstore('_curPluginPage', id);
113 };
114 obj.addPluginEx = function() {
115 meshserver.send({ action: 'addplugin', url: document.getElementById('pluginurlinput').value});
116 };
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');
122 } else {
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');
126 }
127 };
128 obj.refreshPluginHandler = function() {
129 let st = document.createElement('script');
130 st.src = '/pluginHandler.js';
131 document.body.appendChild(st);
132 };
133 return obj; }`;
134 return str;
135 }
136
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");');
141 }
142
143 obj.callHook = function (hookName, ...args) {
144 for (var p in obj.plugins) {
145 if (typeof obj.plugins[p][hookName] == 'function') {
146 try {
147 obj.plugins[p][hookName](...args);
148 } catch (e) {
149 console.log("Error occurred while running plugin hook " + p + ':' + hookName, e);
150 }
151 }
152 }
153 };
154
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'];
167
168 // Merge this module
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);
181 } else {
182 // Add to all cores
183 modulesAdd['windows-amt'].push(...moduleData);
184 modulesAdd['linux-amt'].push(...moduleData);
185 modulesAdd['linux-noamt'].push(...moduleData);
186 }
187
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);
192 }
193 }
194
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);
199 }
200 }
201 }
202 }
203 }
204 }
205 };
206
207 obj.deviceViewPanel = function () {
208 var panel = {};
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") {
211 try {
212 panel[p] = {
213 header: obj.plugins[p].on_device_header(),
214 content: obj.plugins[p].on_device_page()
215 };
216 } catch (e) {
217 console.log("Error occurred while getting plugin views " + p + ':' + ' (' + e + ')');
218 }
219 }
220 }
221 return panel;
222 };
223
224 obj.isValidConfig = function (conf, url) { // check for the required attributes
225 var isValid = true;
226 if (!(
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
241 )) isValid = false;
242 // more checks here?
243 if (conf.repository.type == 'git') {
244 if (typeof conf.downloadUrl != 'string') isValid = false;
245 }
246 return isValid;
247 };
248
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 const getme = new URL(configUrl);
255 var options = { protocol: getme.protocol, hostname: getme.hostname, port: getme.port || undefined, path: getme.pathname + getme.search };
256 if (typeof parent.config.settings.plugins.proxy == 'string' || process.env['HTTP_PROXY'] || process.env['HTTPS_PROXY'] || process.env['http_proxy'] || process.env['https_proxy']) { // Proxy support
257 options.agent = new (require('https-proxy-agent').HttpsProxyAgent)(new URL(parent.config.settings.plugins.proxy) || process.env['HTTP_PROXY'] || process.env['HTTPS_PROXY'] || process.env['http_proxy'] || process.env['https_proxy']);
258 }
259 http.get(options, function (res) {
260 var configStr = '';
261 res.on('data', function (chunk) {
262 configStr += chunk;
263 });
264 res.on('end', function () {
265 if (configStr[0] == '{') { // Let's be sure we're JSON
266 try {
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);
271 } else {
272 reject("This does not appear to be a valid plugin configuration.");
273 }
274 } catch (e) { reject("Error getting plugin config. Check that you have valid JSON."); }
275 } else {
276 reject("Error getting plugin config. Check that you have valid JSON.");
277 }
278 });
279 }).on('error', function (e) {
280 reject("Error getting plugin config: " + e.message);
281 });
282 })
283 };
284
285 // MeshCentral now adheres to semver, drop the -<alpha> off the version number for later versions for comparing plugins prior to this change
286 obj.versionToNumber = function(ver) { var x = ver.split('-'); if (x.length != 2) return ver; return x[0]; }
287
288 // Check if the current version of MeshCentral is at least the minimal required.
289 obj.versionCompare = function(current, minimal) {
290 if (minimal.startsWith('>=')) { minimal = minimal.substring(2); }
291 var c = obj.versionToNumber(current).split('.'), m = obj.versionToNumber(minimal).split('.');
292 if (c.length != m.length) return false;
293 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; } }
294 return true;
295 }
296
297 obj.versionGreater = function(a, b) {
298 a = obj.versionToNumber(String(a).replace(/^v/, ''));
299 b = obj.versionToNumber(String(b).replace(/^v/, ''));
300 const partsA = a.split('.').map(Number);
301 const partsB = b.split('.').map(Number);
302
303 for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
304 const numA = partsA[i] || 0;
305 const numB = partsB[i] || 0;
306 if (numA > numB) return true;
307 if (numA < numB) return false;
308 }
309 return false;
310 };
311
312 obj.versionLower = function(a, b) {
313 a = obj.versionToNumber(String(a).replace(/^v/, ''));
314 b = obj.versionToNumber(String(b).replace(/^v/, ''));
315 const partsA = a.split('.').map(Number);
316 const partsB = b.split('.').map(Number);
317
318 for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
319 const numA = partsA[i] || 0;
320 const numB = partsB[i] || 0;
321 if (numA < numB) return true;
322 if (numA > numB) return false;
323 }
324 return false;
325 };
326
327 obj.getPluginLatest = function () {
328 return new Promise(function (resolve, reject) {
329 parent.db.getPlugins(function (err, plugins) {
330 var proms = [];
331 plugins.forEach(function (curconf) {
332 proms.push(obj.getPluginConfig(curconf.configUrl).catch(e => { return null; }));
333 });
334 var latestRet = [];
335 Promise.all(proms).then(function (newconfs) {
336 var nconfs = [];
337 // Filter out config download issues
338 newconfs.forEach(function (nc) { if (nc !== null) nconfs.push(nc); });
339 if (nconfs.length == 0) { resolve([]); } else {
340 nconfs.forEach(function (newconf) {
341 var curconf = null;
342 plugins.forEach(function (conf) {
343 if (conf.configUrl == newconf.configUrl) curconf = conf;
344 });
345 if (curconf == null) reject("Some plugin configs could not be parsed");
346 latestRet.push({
347 'id': curconf._id,
348 'installedVersion': curconf.version,
349 'version': newconf.version,
350 'hasUpdate': obj.versionGreater(newconf.version, curconf.version),
351 'meshCentralCompat': obj.versionCompare(parent.currentVer, newconf.meshCentralCompat),
352 'changelogUrl': curconf.changelogUrl,
353 'status': curconf.status
354 });
355 resolve(latestRet);
356 });
357 }
358 }).catch((e) => { console.log("Error reaching plugins, update call aborted.", e) });
359 });
360 });
361 };
362
363 obj.addPlugin = function (pluginConfig) {
364 return new Promise(function (resolve, reject) {
365 parent.db.addPlugin({
366 'name': pluginConfig.name,
367 'shortName': pluginConfig.shortName,
368 'version': pluginConfig.version,
369 'description': pluginConfig.description,
370 'hasAdminPanel': pluginConfig.hasAdminPanel,
371 'homepage': pluginConfig.homepage,
372 'changelogUrl': pluginConfig.changelogUrl,
373 'configUrl': pluginConfig.configUrl,
374 'downloadUrl': pluginConfig.downloadUrl,
375 'repository': {
376 'type': pluginConfig.repository.type,
377 'url': pluginConfig.repository.url
378 },
379 'meshCentralCompat': pluginConfig.meshCentralCompat,
380 'versionHistoryUrl': pluginConfig.versionHistoryUrl,
381 'status': 0 // 0: disabled, 1: enabled
382 }, function () {
383 parent.db.getPlugins(function (err, docs) {
384 if (err) reject(err);
385 else resolve(docs);
386 });
387 });
388 });
389 };
390
391 obj.installPlugin = function (id, version_only, force_url, func) {
392 parent.db.getPlugin(id, function (err, docs) {
393 // the "id" would probably suffice, but is probably an sanitary issue, generate a random instead
394 var randId = Math.random().toString(32).replace('0.', '');
395 var tmpDir = require('os').tmpdir();
396 var fileName = obj.parent.path.join(tmpDir, 'Plugin_' + randId + '.zip');
397 try {
398 obj.fs.accessSync(tmpDir, obj.fs.constants.W_OK);
399 } catch (e) {
400 var pluginTmpPath = obj.parent.path.join(obj.pluginPath, '_tmp');
401 if (!obj.fs.existsSync(pluginTmpPath)) {
402 obj.fs.mkdirSync(pluginTmpPath, { recursive: true });
403 }
404 fileName = obj.parent.path.join(pluginTmpPath, 'Plugin_' + randId + '.zip');
405 }
406 var plugin = docs[0];
407 if (plugin.repository.type == 'git') {
408 var file;
409 try {
410 file = obj.fs.createWriteStream(fileName);
411 } catch (e) {
412 if (fileName.indexOf(tmpDir) >= 0) {
413 var pluginTmpPath = obj.parent.path.join(obj.pluginPath, '_tmp');
414 if (!obj.fs.existsSync(pluginTmpPath)) {
415 obj.fs.mkdirSync(pluginTmpPath, { recursive: true });
416 }
417 fileName = obj.parent.path.join(pluginTmpPath, 'Plugin_' + randId + '.zip');
418 file = obj.fs.createWriteStream(fileName);
419 } else {
420 throw e;
421 }
422 }
423 var dl_url = plugin.downloadUrl;
424 if (version_only != null && version_only != false) dl_url = version_only.url;
425 if (force_url != null) dl_url = force_url;
426 var q = new URL(dl_url);
427 var http = (q.protocol == "http:") ? require('http') : require('https');
428 var opts = {
429 path: q.pathname,
430 host: q.hostname,
431 port: q.port,
432 headers: {
433 'User-Agent': 'MeshCentral'
434 },
435 followRedirects: true,
436 method: 'GET'
437 };
438 if (typeof parent.config.settings.plugins.proxy == 'string' || process.env['HTTP_PROXY'] || process.env['HTTPS_PROXY'] || process.env['http_proxy'] || process.env['https_proxy']) { // Proxy support
439 opts.agent = new (require('https-proxy-agent').HttpsProxyAgent)(new URL(parent.config.settings.plugins.proxy) || process.env['HTTP_PROXY'] || process.env['HTTPS_PROXY'] || process.env['http_proxy'] || process.env['https_proxy']);
440 }
441 var done = false;
442 var request = http.get(opts, function (response) {
443 // handle redirections with grace
444 if (response.headers.location) {
445 file.close(() => obj.fs.unlink(fileName, () => {}));
446 return obj.installPlugin(id, version_only, response.headers.location, func);
447 }
448 if ((response.statusCode != null) && (response.statusCode >= 400)) { return console.log('Error downloading plugin: HTTP ' + response.statusCode); }
449 response.pipe(file);
450 file.on('finish', function () {
451 file.close(function () {
452 var yauzl = require('yauzl');
453 if (!obj.fs.existsSync(obj.pluginPath)) {
454 obj.fs.mkdirSync(obj.pluginPath);
455 }
456 if (!obj.fs.existsSync(obj.parent.path.join(obj.pluginPath, plugin.shortName))) {
457 obj.fs.mkdirSync(obj.parent.path.join(obj.pluginPath, plugin.shortName));
458 }
459 yauzl.open(fileName, { lazyEntries: true }, function (err, zipfile) {
460 if (err) throw err;
461 zipfile.readEntry();
462 zipfile.on('entry', function (entry) {
463 let pluginPath = obj.parent.path.join(obj.pluginPath, plugin.shortName);
464 let pathReg = new RegExp(/(.*?\/)/);
465 //if (process.platform == 'win32') { pathReg = new RegExp(/(.*?\\/); }
466 let filePath = obj.parent.path.join(pluginPath, entry.fileName.replace(pathReg, '')); // remove top level dir
467
468 if (/\/$/.test(entry.fileName)) { // dir
469 if (!obj.fs.existsSync(filePath))
470 obj.fs.mkdirSync(filePath);
471 zipfile.readEntry();
472 } else { // file
473 zipfile.openReadStream(entry, function (err, readStream) {
474 if (err) throw err;
475 readStream.on('end', function () { zipfile.readEntry(); });
476 if (process.platform == 'win32') {
477 readStream.pipe(obj.fs.createWriteStream(filePath));
478 } else {
479 var fileMode = (entry.externalFileAttributes >> 16) & 0x0fff;
480 if( fileMode <= 0 ) fileMode = 0o644;
481 readStream.pipe(obj.fs.createWriteStream(filePath, { mode: fileMode }));
482 }
483 });
484 }
485 });
486 zipfile.on('end', function () {
487 setTimeout(function () {
488 try { obj.fs.unlinkSync(fileName); } catch (ex) { }
489 if (version_only == null || version_only === false) {
490 parent.db.setPluginStatus(id, 1, function () { if (done) return; done = true; if (typeof func == 'function') { func(null); } });
491 } else {
492 parent.db.updatePlugin(id, { status: 1, version: version_only.name }, function () { if (done) return; done = true; if (typeof func == 'function') { func(null); } });
493 }
494 try {
495 obj.plugins[plugin.shortName] = require(obj.pluginPath + '/' + plugin.shortName + '/' + plugin.shortName + '.js')[plugin.shortName](obj);
496 obj.exports[plugin.shortName] = obj.plugins[plugin.shortName].exports;
497 if (typeof obj.plugins[plugin.shortName].server_startup == 'function') obj.plugins[plugin.shortName].server_startup();
498 } catch (e) { console.log('Error instantiating new plugin: ', e); }
499 try {
500 var plugin_config = obj.fs.readFileSync(obj.pluginPath + '/' + plugin.shortName + '/config.json');
501 plugin_config = JSON.parse(plugin_config);
502 parent.db.updatePlugin(plugin._id, plugin_config);
503 } catch (e) { console.log('Error reading plugin config upon install'); }
504 parent.updateMeshCore();
505 });
506 });
507 zipfile.on('error', function (e) { console.log('Error extracting plugin ZIP: ' + e.message); });
508 });
509 });
510 });
511 });
512 request.on('error', function (e) { console.log('Error downloading plugin: ' + e.message); });
513 request.setTimeout(30000, function () { request.destroy(new Error('Timed out while downloading plugin')); });
514 } else if (plugin.repository.type == 'npm') {
515 // @TODO npm support? (need a test plugin)
516 }
517 });
518 };
519
520 obj.getPluginVersions = function (id) {
521 return new Promise(function (resolve, reject) {
522 parent.db.getPlugin(id, function (err, docs) {
523 var plugin = docs[0];
524 if (plugin.versionHistoryUrl == null) reject("No version history available for this plugin.");
525 var q = new URL(plugin.versionHistoryUrl);
526 var http = (q.protocol == 'http:') ? require('http') : require('https');
527 var opts = {
528 path: q.pathname,
529 host: q.hostname,
530 port: q.port,
531 headers: {
532 'User-Agent': 'MeshCentral',
533 'Accept': 'application/vnd.github.v3+json'
534 }
535 };
536 if (typeof parent.config.settings.plugins.proxy == 'string' || process.env['HTTP_PROXY'] || process.env['HTTPS_PROXY'] || process.env['http_proxy'] || process.env['https_proxy']) { // Proxy support
537 options.agent = new (require('https-proxy-agent').HttpsProxyAgent)(new URL(parent.config.settings.plugins.proxy) || process.env['HTTP_PROXY'] || process.env['HTTPS_PROXY'] || process.env['http_proxy'] || process.env['https_proxy']);
538 }
539 http.get(opts, function (res) {
540 var versStr = '';
541 res.on('data', function (chunk) {
542 versStr += chunk;
543 });
544 res.on('end', function () {
545 if ((versStr[0] == '{') || (versStr[0] == '[')) { // let's be sure we're JSON
546 try {
547 var vers = JSON.parse(versStr);
548 var vList = [];
549 vers.forEach((v) => {
550 if (obj.versionLower(v.name, plugin.version)) vList.push(v);
551 });
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."); }
555 } else {
556 reject("Version history appears to be malformed." + versStr);
557 }
558 });
559 }).on('error', function (e) {
560 reject("Error getting plugin versions: " + e.message);
561 });
562 });
563 });
564 };
565
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();
573 });
574 };
575
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)) {
581 try {
582 obj.fs.rmSync(pluginPath, { recursive: true, force: true });
583 } catch (e) {
584 console.log("Error removing plugin directory:", e);
585 }
586 }
587 parent.db.deletePlugin(id, func);
588 delete obj.plugins[plugin.shortName];
589 });
590 };
591
592 // Reload a specific plugin without restarting the server
593 // Useful for development and upgrading - call this after modifying plugin files
594 obj.reloadPlugin = function (pluginName, func) {
595 var pluginPath = obj.pluginPath + '/' + pluginName;
596 var mainFile = pluginPath + '/' + pluginName + '.js';
597
598 if (!obj.fs.existsSync(mainFile)) {
599 var errMsg = "Plugin not found: " + pluginName;
600 console.log(errMsg);
601 if (func) func({ success: false, error: errMsg });
602 return;
603 }
604
605 // Clear the require cache for this plugin
606 var resolvedPath = require.resolve(mainFile);
607 if (require.cache[resolvedPath]) {
608 delete require.cache[resolvedPath];
609 }
610
611 // Also try to clear any nested requires (basic approach)
612 Object.keys(require.cache).forEach(function (key) {
613 if (key.startsWith(pluginPath + '/')) {
614 delete require.cache[key];
615 }
616 });
617
618 // Remove old plugin instance
619 delete obj.plugins[pluginName];
620 delete obj.exports[pluginName];
621
622 // Reload the plugin
623 try {
624 obj.plugins[pluginName] = require(mainFile)[pluginName](obj);
625 obj.exports[pluginName] = obj.plugins[pluginName].exports;
626
627 // Call server_startup hook if it exists (re-initializes the plugin)
628 if (typeof obj.plugins[pluginName].server_startup === 'function') {
629 obj.plugins[pluginName].server_startup();
630 }
631
632 console.log("Plugin reloaded successfully: " + pluginName);
633 if (func) func({ success: true, name: pluginName });
634 } catch (e) {
635 var errMsg = "Error reloading plugin " + pluginName + ": " + e;
636 console.log(errMsg, e.stack);
637 if (func) func({ success: false, error: errMsg });
638 }
639 };
640
641 // Reload all enabled plugins
642 obj.reloadAllPlugins = function (func) {
643 var results = [];
644 var pluginNames = Object.keys(obj.plugins);
645
646 if (pluginNames.length === 0) {
647 if (func) func({ success: true, reloaded: [] });
648 return;
649 }
650
651 pluginNames.forEach(function (pluginName) {
652 obj.reloadPlugin(pluginName, function (result) {
653 results.push(result);
654 if (results.length === pluginNames.length) {
655 if (func) func({ success: true, reloaded: results });
656 }
657 });
658 });
659 };
660
661 // In-memory cache of registered permissions (loaded from plugins)
662 obj.pluginPermissions = {};
663 obj.pluginPermissionsCache = {}; // Loaded from database
664
665 // Register a plugin's permissions (called by plugin during load)
666 // permissions: { 'can_edit': { title: 'Edit', desc: 'Can edit', default: 'allowed' }, ... }
667 // default value can be: 'allowed', 'denied', or 'inherited'
668 obj.registerPermissions = function(pluginName, permissions) {
669 var definitions = {};
670 var defaults = {};
671
672 for (var key in permissions) {
673 definitions[key] = {
674 title: permissions[key].title,
675 desc: permissions[key].desc
676 };
677 defaults[key] = permissions[key].default || 'inherited';
678 }
679
680 obj.pluginPermissions[pluginName] = {
681 definitions: definitions,
682 defaults: defaults
683 };
684 //console.log("Registered permissions for plugin: " + pluginName);
685 };
686
687 // Helper to resolve meshId from nodeId (async)
688 obj.resolveMeshFromNode = function(nodeId) {
689 return new Promise(function(resolve, reject) {
690 parent.db.Get(nodeId, function(err, node) {
691 if (err || !node) {
692 resolve(null);
693 } else {
694 resolve(node[0].meshid);
695 }
696 });
697 });
698 };
699
700 // Helper: do the actual permission check (sync)
701 function doCheckPluginPermission(user, pluginName, permission, nodeId, meshId) {
702 return obj.checkPluginPermission(user, pluginName, permission, nodeId, meshId);
703 }
704
705 // New API: Get all permissions for a user/context
706 // Always returns a Promise. Returns an array of permission keys the user has access to.
707 // Usage: const perms = await parent.getAccessPermissions('pluginName', user, { nodeid: 'node/...' })
708 // Returns: ['can_access', 'can_edit', ...]
709 obj.getAccessPermissions = function(pluginName, user, context) {
710 var nodeId = null;
711 var meshId = null;
712
713 if (typeof context === 'string') {
714 nodeId = context;
715 } else if (typeof context === 'object') {
716 nodeId = context.nodeId || context.nodeid;
717 meshId = context.meshId || context.meshid || context.mesh;
718 }
719
720 // If we have nodeId but no meshId, resolve meshId from node
721 var meshPromise;
722 if (nodeId && !meshId) {
723 meshPromise = obj.resolveMeshFromNode(nodeId);
724 } else {
725 meshPromise = Promise.resolve(meshId);
726 }
727
728 return meshPromise.then(function(resolvedMeshId) {
729 var pluginDef = obj.pluginPermissions[pluginName];
730 var permKeys = pluginDef ? Object.keys(pluginDef.definitions) : [];
731
732 var allowedPerms = [];
733 for (var i = 0; i < permKeys.length; i++) {
734 var permKey = permKeys[i];
735 var allowed = doCheckPluginPermission(user, pluginName, permKey, nodeId, resolvedMeshId);
736 if (allowed === true) {
737 allowedPerms.push(permKey);
738 }
739 }
740
741 // Return a function that checks individual permissions
742 return function(permission) {
743 if (permission == '_ALL_') return allowedPerms;
744 return allowedPerms.indexOf(permission) >= 0;
745 };
746 });
747 };
748
749 obj.loadPluginPermissions = function(pluginName, callback) {
750 parent.db.getPluginPermissions(pluginName, function(err, docs) {
751 if (err || docs.length === 0) {
752 // No permissions saved yet, create default structure
753 obj.pluginPermissionsCache[pluginName] = {
754 _id: 'pluginpermission//' + pluginName,
755 pluginName: pluginName,
756 permissions: {},
757 defaults: obj.pluginPermissions[pluginName] ? obj.pluginPermissions[pluginName].defaults : {}
758 };
759 } else {
760 obj.pluginPermissionsCache[pluginName] = docs[0];
761 }
762 if (callback) callback();
763 });
764 };
765
766 obj.getPluginPermissions = function(pluginName) {
767 var cached = obj.pluginPermissionsCache[pluginName];
768 if (!cached) {
769 // Return in-memory registration if no DB entry
770 return obj.pluginPermissions[pluginName] || null;
771 }
772
773 // Merge definitions from plugin registration with saved permissions
774 var definitions = obj.pluginPermissions[pluginName] ? obj.pluginPermissions[pluginName].definitions : {};
775 return {
776 _id: cached._id,
777 pluginName: pluginName,
778 definitions: definitions,
779 defaults: cached.defaults || {},
780 permissions: cached.permissions || {}
781 };
782 };
783
784 obj.setPluginPermissions = function(pluginName, data, callback) {
785 var existing = obj.pluginPermissionsCache[pluginName] || {};
786
787 var doc = {
788 _id: 'pluginpermission//' + pluginName,
789 pluginName: pluginName,
790 permissions: data.permissions || {},
791 defaults: data.defaults || existing.defaults || {}
792 };
793
794 obj.pluginPermissionsCache[pluginName] = doc;
795 parent.db.setPluginPermissions(pluginName, doc, function(err) {
796 if (callback) callback(err);
797 });
798 };
799
800 function userIsInGroup(user, groupId) {
801 if (!user || !user.links) return false;
802 return user.links[groupId] != null;
803 }
804
805 // Evaluate if user has access at a specific level (global, mesh override, node override)
806 // Returns: 'allowed', 'denied', or 'inherited' (not set)
807 function evaluateAccessLevel(entry, user) {
808 if (!entry) return 'inherited';
809
810 var allowed = entry.allowed || {};
811 var denied = entry.denied || {};
812
813 // Check allowed lists first
814 if (allowed.users && allowed.users.indexOf(user._id) >= 0) return 'allowed';
815 if (allowed.userGroups) {
816 for (var i = 0; i < allowed.userGroups.length; i++) {
817 if (userIsInGroup(user, allowed.userGroups[i])) return 'allowed';
818 }
819 }
820
821 // Check denied lists
822 if (denied.users && denied.users.indexOf(user._id) >= 0) return 'denied';
823 if (denied.userGroups) {
824 for (var i = 0; i < denied.userGroups.length; i++) {
825 if (userIsInGroup(user, denied.userGroups[i])) return 'denied';
826 }
827 }
828
829 return 'inherited';
830 }
831
832 // Core permission check function
833 // user: user object from MeshCentral
834 // pluginName: string, e.g., 'regedit'
835 // permission: string, e.g., 'can_edit'
836 // nodeId: optional node ID to check node-specific permissions
837 // meshId: optional mesh ID (if not provided, derived from node)
838 obj.checkPluginPermission = function(user, pluginName, permission, nodeId, meshId) {
839 // 1. Full admin always has access
840 if (user.siteadmin === 0xFFFFFFFF) return true;
841
842 // 2. Get plugin permissions config
843 var config = obj.getPluginPermissions(pluginName);
844 if (!config) {
845 // No permissions defined, allow by default (backwards compatibility)
846 return true;
847 }
848
849 // 3. Get permissions for this specific permission key
850 var permConfig = config.permissions ? config.permissions[permission] : null;
851 if (!permConfig) {
852 permConfig = {
853 allowed: { users: [], userGroups: [], meshes: [], nodes: [] },
854 denied: { users: [], userGroups: [], meshes: [], nodes: [] },
855 meshOverrides: {},
856 nodeOverrides: {}
857 };
858 }
859
860 // 4. Resolve mesh if we have a node but no mesh
861 var targetMesh = meshId;
862 var targetNode = nodeId;
863
864 if (targetNode && !targetMesh) {
865 // Try to get mesh from node cache
866 // MeshCentral typically stores nodes at parent.nodes
867 var node = null;
868
869 // Try to get node from parent.nodes
870 if (obj.parent.nodes && obj.parent.nodes[targetNode]) {
871 node = obj.parent.nodes[targetNode];
872 } else if (parent.meshes) {
873 // Check each mesh's nodes
874 for (var mid in parent.meshes) {
875 var mesh = parent.meshes[mid];
876 if (mesh.nodes && mesh.nodes[targetNode]) {
877 node = mesh.nodes[targetNode];
878 break;
879 }
880 }
881 }
882
883 if (node && node.meshid) {
884 targetMesh = node.meshid;
885 }
886 }
887
888 // 5. Check cascade: Node → Mesh → Global → Default
889
890 // A) Check node-specific (highest priority)
891 if (targetNode && permConfig.nodeOverrides && permConfig.nodeOverrides[targetNode]) {
892 var result = evaluateAccessLevel(permConfig.nodeOverrides[targetNode], user);
893 if (result !== 'inherited') return result === 'allowed';
894 }
895
896 // B) Check mesh-specific
897 if (targetMesh && permConfig.meshOverrides && permConfig.meshOverrides[targetMesh]) {
898 // Verify user has access to this mesh before applying mesh override
899 // User has mesh access if they have a link to the mesh
900 var userHasMeshAccess = (user.links && user.links[targetMesh]) ? true : false;
901
902 if (userHasMeshAccess) {
903 var result = evaluateAccessLevel(permConfig.meshOverrides[targetMesh], user);
904 if (result !== 'inherited') return result === 'allowed';
905 }
906 }
907
908 // C) Check global level
909 var globalResult = evaluateAccessLevel(permConfig, user);
910 if (globalResult !== 'inherited') return globalResult === 'allowed';
911
912 // D) Fall back to default
913 var defaultValue = config.defaults ? config.defaults[permission] : 'inherited';
914 if (defaultValue === 'inherited') {
915 // If default is also inherited, use 'allowed' as safe fallback
916 defaultValue = 'allowed';
917 }
918 return defaultValue === 'allowed';
919 };
920
921 obj.initPluginPermissions = function() {
922 parent.db.getPlugins(function(err, plugins) {
923 if (err || !plugins) return;
924 plugins.forEach(function(plugin) {
925 if (plugin.status === 1 && plugin.shortName) {
926 obj.loadPluginPermissions(plugin.shortName);
927 }
928 });
929 });
930 };
931
932 // Call init on load
933 obj.initPluginPermissions();
934
935 obj.handleAdminReq = function (req, res, user, serv) {
936 if ((req.query.pin == null) || (obj.common.isAlphaNumeric(req.query.pin) !== true)) { res.sendStatus(401); return; }
937 var path = obj.path.join(obj.pluginPath, req.query.pin, 'views');
938 serv.app.set('views', path);
939 if ((obj.plugins[req.query.pin] != null) && (typeof obj.plugins[req.query.pin].handleAdminReq == 'function')) {
940 obj.plugins[req.query.pin].handleAdminReq(req, res, user);
941 } else {
942 res.sendStatus(401);
943 }
944 }
945
946 obj.handleAdminPostReq = function (req, res, user, serv) {
947 if ((req.query.pin == null) || (obj.common.isAlphaNumeric(req.query.pin) !== true)) { res.sendStatus(401); return; }
948 var path = obj.path.join(obj.pluginPath, req.query.pin, 'views');
949 serv.app.set('views', path);
950 if ((obj.plugins[req.query.pin] != null) && (typeof obj.plugins[req.query.pin].handleAdminPostReq == 'function')) {
951 obj.plugins[req.query.pin].handleAdminPostReq(req, res, user);
952 } else {
953 res.sendStatus(401);
954 }
955 }
956 return obj;
957 };