Tweaks to plugin install/removal so server does not require a restart. Initial support for downgrading plugins.

Ryan Blenis committed Nov 22, 2019 at 14:25 UTC 145c898c709e77bb96e92d94c44b6b634debb3ea
5 files changed +165 -19
db.js
+4
@@ -766,6 +766,8 @@ module.exports.CreateDB = function (parent, func) {
766
767 obj.setPluginStatus = function(id, status, func) { id = require('mongodb').ObjectID(id); obj.pluginsfile.updateOne({ _id: id }, { $set: {status: status } }, func); };
768
769 + obj.updatePlugin = function(id, args, func) { delete args._id; id = require('mongodb').ObjectID(id); obj.pluginsfile.updateOne({ _id: id }, { $set: args }, func); };
770 +
771 } else {
772 // Database actions on the main collection (NeDB and MongoJS)
773 obj.Set = function (data, func) {
@@ -910,6 +912,8 @@ module.exports.CreateDB = function (parent, func) {
912
913 obj.setPluginStatus = function(id, status, func) { obj.pluginsfile.update({ _id: id }, { $set: {status: status } }, func); };
914
915 + obj.updatePlugin = function(id, args, func) { delete args._id; obj.pluginsfile.update({ _id: id }, { $set: args }, func); };
916 +
917 }
918
919 func(obj); // Completed function setup
meshuser.js
+17 -3
@@ -3147,11 +3147,12 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
3147 }
3148 case 'installplugin': {
3149 if ((user.siteadmin & 0xFFFFFFFF) == 0 || parent.parent.pluginHandler == null) break; // must be full admin, plugins enabled
3150 - parent.parent.pluginHandler.installPlugin(command.id, function(){
3151 - parent.parent.updateMeshCore();
3150 + parent.parent.pluginHandler.installPlugin(command.id, command.version_only, function(){
3151 parent.db.getPlugins(function(err, docs) {
3152 try { ws.send(JSON.stringify({ action: 'updatePluginList', list: docs, result: err })); } catch (ex) { }
3153 });
3154 + var targets = ['*', 'server-users'];
3155 + parent.parent.DispatchEvent(targets, obj, { action: 'pluginStateChange' });
3156 });
3157 break;
3158 }
@@ -3160,7 +3161,8 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
3161 parent.parent.pluginHandler.disablePlugin(command.id, function(){
3162 parent.db.getPlugins(function(err, docs) {
3163 try { ws.send(JSON.stringify({ action: 'updatePluginList', list: docs, result: err })); } catch (ex) { }
3163 - // @TODO delete plugin object from handler
3164 + var targets = ['*', 'server-users'];
3165 + parent.parent.DispatchEvent(targets, obj, { action: 'pluginStateChange' });
3166 });
3167 });
3168 break;
@@ -3174,6 +3176,18 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use
3176 });
3177 break;
3178 }
3179 + case 'getpluginversions': {
3180 + if ((user.siteadmin & 0xFFFFFFFF) == 0 || parent.parent.pluginHandler == null) break; // must be full admin, plugins enabled
3181 + parent.parent.pluginHandler.getPluginVersions(command.id)
3182 + .then(function (versionInfo) {
3183 + try { ws.send(JSON.stringify({ action: 'downgradePluginVersions', info: versionInfo, error: null })); } catch (ex) { }
3184 + })
3185 + .catch(function (e) {
3186 + try { ws.send(JSON.stringify({ action: 'pluginError', msg: e })); } catch (ex) { }
3187 + });
3188 +
3189 + break;
3190 + }
3191 case 'plugin': {
3192 if (parent.parent.pluginHandler == null) break; // If the plugin's are not supported, reject this command.
3193 command.userid = user._id;
pluginHandler.js
+98 -9
@@ -38,6 +38,11 @@ module.exports.pluginHandler = function (parent) {
38 } catch (e) {
39 console.log("Error loading plugin: " + plugin.shortName + " (" + e + "). It has been disabled.", e.stack);
40 }
41 + try { // try loading local info about plugin to database (if it changed locally)
42 + var plugin_config = obj.fs.readFileSync(obj.pluginPath + '/' + plugin.shortName + '/config.json');
43 + plugin_config = JSON.parse(plugin_config);
44 + parent.db.updatePlugin(plugin._id, plugin_config);
45 + } catch (e) { console.log('Plugin config file for '+ plugin.name +' could not be parsed.'); }
46 }
47 obj.parent.updateMeshCore(); // db calls are delayed, lets inject here once we're ready
48 });
@@ -93,10 +98,21 @@ module.exports.pluginHandler = function (parent) {
98 setDialogMode(2, "Plugin Config URL", 3, obj.addPluginEx, '<input type=text id=pluginurlinput style=width:100% />');
99 focusTextBox('pluginurlinput');
100 };
101 + obj.refreshPluginHandler = function() {
102 + let st = document.createElement('script');
103 + st.src = '/pluginHandler.js';
104 + document.body.appendChild(st);
105 + };
106 return obj; };`;
107 return str;
108 }
99 -
109 +
110 + obj.refreshJS = function(req, res) {
111 + // to minimize server reboots when installing new plugins, we call the new data and overwrite the old pluginHandler on the front end
112 + res.set('Content-Type', 'text/javascript');
113 + res.send('pluginHandlerBuilder = '+obj.prepExports() + ' pluginHandler = new pluginHandlerBuilder();');
114 + }
115 +
116 obj.callHook = function (hookName, ...args) {
117 for (var p in obj.plugins) {
118 if (typeof obj.plugins[p][hookName] == 'function') {
@@ -182,7 +198,7 @@ module.exports.pluginHandler = function (parent) {
198 typeof conf.name == 'string'
199 && typeof conf.shortName == 'string'
200 && typeof conf.version == 'string'
185 - && typeof conf.author == 'string'
201 + // && typeof conf.author == 'string'
202 && typeof conf.description == 'string'
203 && typeof conf.hasAdminPanel == 'boolean'
204 && typeof conf.homepage == 'string'
@@ -290,6 +306,7 @@ module.exports.pluginHandler = function (parent) {
306 "url": pluginConfig.repository.url
307 },
308 "meshCentralCompat": pluginConfig.meshCentralCompat,
309 + "versionHistoryUrl": pluginConfig.versionHistoryUrl,
310 "status": 0 // 0: disabled, 1: enabled
311 }, function() {
312 parent.db.getPlugins(function(err, docs){
@@ -300,16 +317,32 @@ module.exports.pluginHandler = function (parent) {
317 });
318 };
319
303 - obj.installPlugin = function(id, func) {
320 + obj.installPlugin = function(id, version_only, func) {
321 parent.db.getPlugin(id, function(err, docs){
305 - var http = require('https');
322 // the "id" would probably suffice, but is probably an sanitary issue, generate a random instead
323 var randId = Math.random().toString(32).replace('0.', '');
324 var fileName = obj.parent.path.join(require('os').tmpdir(), 'Plugin_'+randId+'.zip');
325 var plugin = docs[0];
326 if (plugin.repository.type == 'git') {
327 const file = obj.fs.createWriteStream(fileName);
312 - var request = http.get(plugin.downloadUrl, function(response) {
328 + var dl_url = plugin.downloadUrl;
329 + if (version_only != null && version_only != false) dl_url = version_only.url;
330 + var url = require('url');
331 + var q = url.parse(dl_url, true);
332 + var http = (q.protocol == "http") ? require('http') : require('https');
333 + var opts = {
334 + path: q.pathname,
335 + host: q.hostname,
336 + port: q.port,
337 + headers: {
338 + 'User-Agent': 'MeshCentral'
339 + },
340 + followRedirects: true,
341 + method: 'GET'
342 + };
343 + var request = http.get(opts, function(response) {
344 + // handle redirections with grace
345 + if (response.headers.location) return obj.installPlugin(id, { name: version_only.name, url: response.headers.location }, func);
346 response.pipe(file);
347 file.on('finish', function() {
348 file.close(function(){
@@ -341,18 +374,24 @@ module.exports.pluginHandler = function (parent) {
374 });
375 }
376 });
344 - zipfile.on("end", function () { setTimeout(function () {
377 + zipfile.on("end", function () { setTimeout(function () {
378 obj.fs.unlinkSync(fileName);
346 - parent.db.setPluginStatus(id, 1, func);
379 + if (version_only == null || version_only === false) {
380 + parent.db.setPluginStatus(id, 1, func);
381 + } else {
382 + parent.db.updatePlugin(id, { status: 1, version: version_only.name }, func);
383 + }
384 obj.plugins[plugin.shortName] = require(obj.pluginPath + '/' + plugin.shortName + '/' + plugin.shortName + '.js')[plugin.shortName](obj);
385 obj.exports[plugin.shortName] = obj.plugins[plugin.shortName].exports;
386 + if (typeof obj.plugins[plugin.shortName].server_startup == 'function') obj.plugins[plugin.shortName].server_startup();
387 + parent.updateMeshCore();
388 }); });
389 });
390 });
391 });
392 });
393 } else if (plugin.repository.type == 'npm') {
355 - // @TODO npm install and symlink dirs (need a test plugin)
394 + // @TODO npm support? (need a test plugin)
395 }
396
397
@@ -361,8 +400,58 @@ module.exports.pluginHandler = function (parent) {
400
401 };
402
403 + obj.getPluginVersions = function(id) {
404 + return new Promise(function(resolve, reject) {
405 + parent.db.getPlugin(id, function(err, docs) {
406 + var plugin = docs[0];
407 + if (plugin.versionHistoryUrl == null) reject('No version history available for this plugin.');
408 + var url = require('url');
409 + var q = url.parse(plugin.versionHistoryUrl, true);
410 + var http = (q.protocol == "http") ? require('http') : require('https');
411 + var opts = {
412 + path: q.pathname,
413 + host: q.hostname,
414 + port: q.port,
415 + headers: {
416 + 'User-Agent': 'MeshCentral',
417 + 'Accept': 'application/vnd.github.v3+json'
418 + }
419 + };
420 + http.get(opts, function(res) {
421 + var versStr = '';
422 + res.on('data', function(chunk){
423 + versStr += chunk;
424 + });
425 + res.on('end', function(){
426 + if (versStr[0] == '{' || versStr[0] == '[') { // let's be sure we're JSON
427 + try {
428 + var vers = JSON.parse(versStr);
429 + var vList = [];
430 + var s = require('semver');
431 + vers.forEach((v) => {
432 + if (s.lt(v.name, plugin.version)) vList.push(v);
433 + });
434 + if (vers.length == 0) reject('No previous versions available.');
435 + resolve({ 'id': plugin._id, 'name': plugin.name, versionList: vList });
436 + } catch (e) { reject('Version history problem.'); }
437 + } else {
438 + reject('Version history appears to be malformed.'+versStr);
439 + }
440 + });
441 + }).on('error', function(e) {
442 + reject("Error getting plugin versions: " + e.message);
443 + });
444 + });
445 + });
446 + };
447 +
448 obj.disablePlugin = function(id, func) {
365 - parent.db.setPluginStatus(id, 0, func);
449 + parent.db.getPlugin(id, function(err, docs){
450 + var plugin = docs[0];
451 + parent.db.setPluginStatus(id, 0, func);
452 + delete obj.plugins[plugin.shortName];
453 + delete obj.exports[plugin.shortName];
454 + });
455 };
456
457 obj.removePlugin = function(id, func) {
views/default.handlebars
+35 -7
@@ -423,7 +423,7 @@
423 <table id="p7tbl">
424 <tr><th class="chName">Name</th><th class="chDescription">Description</th><th class="chSite">Link</th><th class="chVersion">Version</th><th class="chUpgradeAvail">Latest Available</th><th class="chStatus">Status</th><th class="chAction">Action</th></tr>
425 </table>
426 - <div id="pluginRestartNotice" style="display:none;"><div>Notice:</div> MeshCentral restart required to complete plugin changes.</div>
426 + <div id="pluginRestartNotice" style="display:none;"><div>Notice:</div> MeshCentral plugins have been altered. Agent cores require may require an update before full features are available.</div>
427 </div>
428 <div id=p10 style="display:none">
429 <table style="width:100%" cellpadding="0" cellspacing="0">
@@ -2313,6 +2313,11 @@
2313 updatePluginList();
2314 break;
2315 }
2316 + case 'pluginStateChange': {
2317 + if (pluginHandler == null) break;
2318 + pluginHandler.refreshPluginHandler();
2319 + break;
2320 + }
2321 default:
2322 //console.log('Unknown message.event.action', message.event.action);
2323 break;
@@ -2376,6 +2381,15 @@
2381 updatePluginList(message.list);
2382 break;
2383 }
2384 + case 'downgradePluginVersions': {
2385 + var vSelect = '<select id="lastPluginVersion">';
2386 + message.info.versionList.forEach(function(v){
2387 + vSelect += '<option value="' + v.zipball_url + '">' + v.name + '</option>';
2388 + });
2389 + vSelect += '</select>';
2390 + setDialogMode(2, 'Plugin Action', 3, pluginActionEx, 'Select the version to downgrade the plugin: ' + message.info.name + '<hr />' + vSelect + '<hr />Please be aware that downgrading is not recommended. Please only do so in the event that a recent upgrade has broken something.<input id="lastPluginAct" type="hidden" value="downgrade" /><input id="lastPluginId" type="hidden" value="' + message.info.id + '" />');
2391 + break;
2392 + }
2393 case 'pluginError': {
2394 setDialogMode(2, 'Oops!', 1, null, message.msg);
2395 break;
@@ -9480,7 +9494,8 @@
9494 },
9495 1: {
9496 'disable': 'Disable',
9483 - 'upgrade': 'Upgrade'
9497 + 'upgrade': 'Upgrade',
9498 + // 'downgrade': 'Downgrade' // disabling until plugins have prior versions available for better testing
9499 }
9500 };
9501 var vers_not_compat = ` [ <span onclick="return setDialogMode(2, 'Compatibility Issue', 1, null, 'This plugin version is not compatible with your MeshCentral installation, please upgrade MeshCentral first.');" title="Version incompatible, please upgrade your MeshCentral installation first" style="cursor: pointer; color:red;"> ! </span> ]`;
@@ -9488,7 +9503,7 @@
9503 var tbl = Q('p7tbl');
9504 installedPluginList.forEach(function(p){
9505 var cant_action = [];
9491 - if (p.hasAdminPanel == true) {
9506 + if (p.hasAdminPanel == true && p.status) {
9507 p.nameHtml = `<a onclick="return goPlugin('${p.shortName}', '${p.name}');">${p.name}</a>`;
9508 } else {
9509 p.nameHtml = p.name;
@@ -9496,7 +9511,9 @@
9511 p.statusText = statusMap[p.status].text;
9512 p.statusColor = statusMap[p.status].color;
9513
9499 -
9514 + if (p.versionHistoryUrl == null) {
9515 + cant_action.push('downgrade');
9516 + }
9517
9518 if (!p.status) { // It isn't technically installed, so no version number
9519 p.version = ' - ';
@@ -9547,11 +9564,18 @@
9564 }
9565
9566 function pluginActionEx() {
9550 - var act = Q('lastPluginAct').value, id = Q('lastPluginId').value;
9567 + var act = Q('lastPluginAct').value, id = Q('lastPluginId').value, pVersUrl = Q('lastPluginVersion').value;
9568 +
9569 switch(act) {
9570 case 'upgrade':
9571 case 'install':
9554 - meshserver.send({ "action": "installplugin", "id": id });
9572 + meshserver.send({ "action": "installplugin", "id": id, "version_only": false });
9573 + break;
9574 + case 'downgrade':
9575 + Q('lastPluginVersion').querySelectorAll('option').forEach(function(opt) {
9576 + if (opt.value == pVersUrl) pVers = opt.text;
9577 + });
9578 + meshserver.send({ "action": "installplugin", "id": id, "version_only": { "name": pVers, "url": pVersUrl }});
9579 break;
9580 case 'delete':
9581 meshserver.send({ "action": "removeplugin", "id": id });
@@ -9564,7 +9588,11 @@
9588 }
9589
9590 function pluginAction(elem, id) {
9567 - setDialogMode(2, 'Plugin Action', 3, pluginActionEx, 'Are you sure you want to ' + elem.value + ' the plugin: ' + elem.parentNode.parentNode.firstChild.innerText+'<input id="lastPluginAct" type="hidden" value="' + elem.value + '" /><input id="lastPluginId" type="hidden" value="' + elem.parentNode.parentNode.getAttribute('data-id') + '" />');
9591 + if (elem.value == 'downgrade') {
9592 + meshserver.send({ "action": "getpluginversions", "id": id });
9593 + } else {
9594 + setDialogMode(2, 'Plugin Action', 3, pluginActionEx, 'Are you sure you want to ' + elem.value + ' the plugin: ' + elem.parentNode.parentNode.firstChild.innerText+'<input id="lastPluginAct" type="hidden" value="' + elem.value + '" /><input id="lastPluginId" type="hidden" value="' + elem.parentNode.parentNode.getAttribute('data-id') + '" /><input id="lastPluginVersion" type="hidden" value="" />');
9595 + }
9596 elem.value = '';
9597 }
9598
webserver.js
+11
@@ -3208,6 +3208,16 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
3208
3209 parent.pluginHandler.handleAdminPostReq(req, res, user, obj);
3210 }
3211 +
3212 + obj.handlePluginJS = function(req, res) {
3213 + const domain = checkUserIpAddress(req, res);
3214 + if (domain == null) { res.sendStatus(404); return; }
3215 + if ((!req.session) || (req.session == null) || (!req.session.userid)) { res.sendStatus(401); return; }
3216 + var user = obj.users[req.session.userid];
3217 + if (user == null) { res.sendStatus(401); return; }
3218 +
3219 + parent.pluginHandler.refreshJS(req, res);
3220 + }
3221
3222 // Starts the HTTPS server, this should be called after the user/mesh tables are loaded
3223 function serverStart() {
@@ -3334,6 +3344,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) {
3344 if (parent.pluginHandler != null) {
3345 obj.app.get(url + 'pluginadmin.ashx', obj.handlePluginAdminReq);
3346 obj.app.post(url + 'pluginadmin.ashx', obj.handlePluginAdminPostReq);
3347 + obj.app.get(url + 'pluginHandler.js', obj.handlePluginJS);
3348 }
3349
3350 // Server redirects