diff --git a/db.js b/db.js index 547efc1b..07a0e3f0 100644 --- a/db.js +++ b/db.js @@ -442,6 +442,9 @@ module.exports.CreateDB = function (parent, func) { }); } }); + + // Setup plugin info collection + obj.pluginsfile = db.collection('plugins'); setupFunctions(func); // Completed setup of MongoDB }); @@ -543,6 +546,9 @@ module.exports.CreateDB = function (parent, func) { }); } }); + + // Setup plugin info collection + obj.pluginsfile = db.collection('plugins'); setupFunctions(func); // Completed setup of MongoJS } else { @@ -604,6 +610,10 @@ module.exports.CreateDB = function (parent, func) { obj.serverstatsfile.ensureIndex({ fieldName: 'time', expireAfterSeconds: 60 * 60 * 24 * 30 }); // Limit the server stats log to 30 days (Seconds * Minutes * Hours * Days) obj.serverstatsfile.ensureIndex({ fieldName: 'expire', expireAfterSeconds: 0 }); // Auto-expire events + // Setup plugin info collection + obj.pluginsfile = new Datastore({ filename: parent.getConfigFilePath('meshcentral-plugins.db'), autoload: true }); + obj.pluginsfile.persistence.setAutocompactionInterval(36000); + setupFunctions(func); // Completed setup of NeDB } @@ -754,6 +764,23 @@ module.exports.CreateDB = function (parent, func) { func(r); }); } + + // Add a plugin + obj.addPlugin = function (plugin, func) { plugin.type = "plugin"; obj.pluginsfile.insertOne(plugin, func); }; + + // Get all plugins + obj.getPlugins = function (func) { obj.pluginsfile.find({"type": "plugin"}).project({"type": 0}).sort({ name: 1 }).toArray(func); }; + + // Get plugin + obj.getPlugin = function (id, func) { id = require('mongodb').ObjectID(id); obj.pluginsfile.find({ _id: id }).sort({ name: 1 }).toArray(func); }; + + // Delete plugin + obj.deletePlugin = function (id, func) { id = require('mongodb').ObjectID(id); obj.pluginsfile.deleteOne({ _id: id }, func); }; + + obj.setPluginStatus = function(id, status, func) { id = require('mongodb').ObjectID(id); obj.pluginsfile.updateOne({ _id: id }, { $set: {status: status } }, func); }; + + obj.updatePlugin = function(id, args, func) { delete args._id; id = require('mongodb').ObjectID(id); obj.pluginsfile.updateOne({ _id: id }, { $set: args }, func); }; + } else { // Database actions on the main collection (NeDB and MongoJS) obj.Set = function (data, func) { @@ -884,6 +911,23 @@ module.exports.CreateDB = function (parent, func) { func(r); }); } + + // Add a plugin + obj.addPlugin = function (plugin, func) { plugin.type = "plugin"; obj.pluginsfile.insert(plugin, func); }; + + // Get all plugins + obj.getPlugins = function (func) { obj.pluginsfile.find({"type": "plugin"}, {"type": 0}).sort({ name: 1 }).exec(func); }; + + // Get plugin + obj.getPlugin = function (id, func) { obj.pluginsfile.find({ _id: id }).sort({ name: 1 }).exec(func); }; + + // Delete plugin + obj.deletePlugin = function (id, func) { obj.pluginsfile.remove({ _id: id }, func); }; + + obj.setPluginStatus = function(id, status, func) { obj.pluginsfile.update({ _id: id }, { $set: {status: status } }, func); }; + + obj.updatePlugin = function(id, args, func) { delete args._id; obj.pluginsfile.update({ _id: id }, { $set: args }, func); }; + } func(obj); // Completed function setup diff --git a/meshagent.js b/meshagent.js index 51282ad6..7130b3c0 100644 --- a/meshagent.js +++ b/meshagent.js @@ -1295,8 +1295,7 @@ module.exports.CreateMeshAgent = function (parent, db, ws, req, args, domain) { case 'plugin': { if ((parent.parent.pluginHandler == null) || (typeof command.plugin != 'string')) break; try { - var pluginHandler = require('./pluginHandler.js').pluginHandler(parent.parent); - pluginHandler.plugins[command.plugin].serveraction(command, obj, parent); + parent.parent.pluginHandler.plugins[command.plugin].serveraction(command, obj, parent); } catch (e) { console.log('Error loading plugin handler (' + e + ')'); } diff --git a/meshuser.js b/meshuser.js index 76dd9992..a4780c80 100644 --- a/meshuser.js +++ b/meshuser.js @@ -3137,6 +3137,88 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use } break; } + case 'distributeCore': { + for (var i in command.nodes) { + parent.sendMeshAgentCore(user, domain, command.nodes[i]._id, 'default'); + } + break; + } + case 'plugins': { + // Since plugin actions generally require a server restart, use the Full admin permission + if ((user.siteadmin & 0xFFFFFFFF) == 0 || parent.parent.pluginHandler == null) break; // must be full admin with plugins enabled + parent.db.getPlugins(function(err, docs) { + try { ws.send(JSON.stringify({ action: 'updatePluginList', list: docs, result: err })); } catch (ex) { } + }); + break; + } + case 'pluginLatestCheck': { + if ((user.siteadmin & 0xFFFFFFFF) == 0 || parent.parent.pluginHandler == null) break; // must be full admin with plugins enabled + parent.parent.pluginHandler.getPluginLatest() + .then(function(latest) { + try { ws.send(JSON.stringify({ action: 'pluginVersionsAvailable', list: latest })); } catch (ex) { } + }); + break; + } + case 'addplugin': { + if ((user.siteadmin & 0xFFFFFFFF) == 0 || parent.parent.pluginHandler == null) break; // must be full admin, plugins enabled + try { + parent.parent.pluginHandler.getPluginConfig(command.url) + .then(parent.parent.pluginHandler.addPlugin) + .then(function(docs){ + var targets = ['*', 'server-users']; + parent.parent.DispatchEvent(targets, obj, { action: 'updatePluginList', list: docs }); + }) + .catch(function(err) { + if (typeof err == 'object') err = err.message; + try { ws.send(JSON.stringify({ action: 'pluginError', msg: err })); } catch (er) { } + }); + + } catch(e) { console.log('Cannot add plugin: ' + e); } + break; + } + case 'installplugin': { + if ((user.siteadmin & 0xFFFFFFFF) == 0 || parent.parent.pluginHandler == null) break; // must be full admin, plugins enabled + parent.parent.pluginHandler.installPlugin(command.id, command.version_only, null, function(){ + parent.db.getPlugins(function(err, docs) { + try { ws.send(JSON.stringify({ action: 'updatePluginList', list: docs, result: err })); } catch (ex) { } + }); + var targets = ['*', 'server-users']; + parent.parent.DispatchEvent(targets, obj, { action: 'pluginStateChange' }); + }); + break; + } + case 'disableplugin': { + if ((user.siteadmin & 0xFFFFFFFF) == 0 || parent.parent.pluginHandler == null) break; // must be full admin, plugins enabled + parent.parent.pluginHandler.disablePlugin(command.id, function(){ + parent.db.getPlugins(function(err, docs) { + try { ws.send(JSON.stringify({ action: 'updatePluginList', list: docs, result: err })); } catch (ex) { } + var targets = ['*', 'server-users']; + parent.parent.DispatchEvent(targets, obj, { action: 'pluginStateChange' }); + }); + }); + break; + } + case 'removeplugin': { + if ((user.siteadmin & 0xFFFFFFFF) == 0 || parent.parent.pluginHandler == null) break; // must be full admin, plugins enabled + parent.parent.pluginHandler.removePlugin(command.id, function(){ + parent.db.getPlugins(function(err, docs) { + try { ws.send(JSON.stringify({ action: 'updatePluginList', list: docs, result: err })); } catch (ex) { } + }); + }); + break; + } + case 'getpluginversions': { + if ((user.siteadmin & 0xFFFFFFFF) == 0 || parent.parent.pluginHandler == null) break; // must be full admin, plugins enabled + parent.parent.pluginHandler.getPluginVersions(command.id) + .then(function (versionInfo) { + try { ws.send(JSON.stringify({ action: 'downgradePluginVersions', info: versionInfo, error: null })); } catch (ex) { } + }) + .catch(function (e) { + try { ws.send(JSON.stringify({ action: 'pluginError', msg: e })); } catch (ex) { } + }); + + break; + } case 'plugin': { if (parent.parent.pluginHandler == null) break; // If the plugin's are not supported, reject this command. command.userid = user._id; @@ -3144,8 +3226,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use routeCommandToNode(command); } else { try { - var pluginHandler = require('./pluginHandler.js').pluginHandler(parent.parent); - pluginHandler.plugins[command.plugin].serveraction(command, obj, parent); + parent.parent.pluginHandler.plugins[command.plugin].serveraction(command, obj, parent); } catch (e) { console.log('Error loading plugin handler (' + e + ')'); } } break; diff --git a/pluginHandler.js b/pluginHandler.js index cb96ec42..0c70e55d 100644 --- a/pluginHandler.js +++ b/pluginHandler.js @@ -13,6 +13,7 @@ /*jshint strict: false */ /*jshint esversion: 6 */ "use strict"; +require('promise'); module.exports.pluginHandler = function (parent) { var obj = {}; @@ -23,43 +24,64 @@ module.exports.pluginHandler = function (parent) { obj.pluginPath = obj.parent.path.join(obj.parent.datapath, 'plugins'); obj.plugins = {}; obj.exports = {}; - obj.loadList = obj.parent.config.settings.plugins.list; - + obj.loadList = obj.parent.config.settings.plugins.list; // For local development / manual install, not from DB + if (typeof obj.loadList != 'object') { obj.loadList = {}; - console.log('Plugin list not specified, please fix configuration file.'); - return null; - } - - 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); + 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 delayed, 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 (const p of Object.keys(obj.plugins)) { str += ' obj.' + p + ' = {};\r\n'; - for (const l of Object.values(obj.exports[p])) { - str += ' obj.' + p + '.' + l + ' = ' + obj.plugins[p][l].toString() + '\r\n'; + if (Array.isArray(obj.exports[p])) { + for (const l of Object.values(obj.exports[p])) { + str += ' obj.' + p + '.' + l + ' = ' + obj.plugins[p][l].toString() + '\r\n'; + } } } - str += `obj.onDeviceRefeshEnd = function(nodeid, panel, refresh, event) { - for (const p of Object.keys(obj)) { - if (typeof obj[p].onDeviceRefreshEnd == 'function') { - obj[p].onDeviceRefreshEnd(nodeid, panel, refresh, event); + 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); } } }; + obj.registerPluginTab = function(pluginRegInfo) { var d = pluginRegInfo(); if (!Q(d.tabId)) { @@ -71,10 +93,28 @@ module.exports.pluginHandler = function (parent) { for (const i of pages) { i.style.display = 'none'; } QV(id, true); }; + obj.addPluginEx = function() { + meshserver.send({ action: 'addplugin', url: Q('pluginurlinput').value}); + }; + obj.addPluginDlg = function() { + setDialogMode(2, "Plugin Config URL", 3, obj.addPluginEx, ''); + 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() + ' pluginHandler = new pluginHandlerBuilder();'); + } + obj.callHook = function (hookName, ...args) { for (var p in obj.plugins) { if (typeof obj.plugins[p][hookName] == 'function') { @@ -152,7 +192,311 @@ module.exports.pluginHandler = function (parent) { } } 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; + }; + + obj.getPluginConfig = function(configUrl) { + return new Promise(function(resolve, reject) { + if (configUrl.indexOf('https://') >= 0) { + var http = require('https'); + } else { + var http = require('http'); + } + if (configUrl.indexOf('://') === -1) reject('Unable to fetch the config: Bad URL (' + configUrl + ')'); + http.get(configUrl, 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); + }); + }) + }; + + 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); + }); + 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'); + var s = require('semver'); + // MeshCentral doesn't adhere to semantic versioning (due to the - at the end of the version) + // Convert the letter to ASCII for a "true" version number comparison + var mcCurVer = parent.currentVer.replace(/-(.)$/, (m, p1) => { return p1.charCodeAt(0); }); + var piCompatVer = newconf.meshCentralCompat.replace(/-(.)\b/g, (m, p1) => { return p1.charCodeAt(0); }); + latestRet.push({ + "id": curconf._id, + "installedVersion": curconf.version, + "version": newconf.version, + "hasUpdate": s.gt(newconf.version, curconf.version), + "meshCentralCompat": s.satisfies(mcCurVer, piCompatVer), + "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 fileName = obj.parent.path.join(require('os').tmpdir(), 'Plugin_'+randId+'.zip'); + var plugin = docs[0]; + if (plugin.repository.type == 'git') { + const file = obj.fs.createWriteStream(fileName); + 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' + }; + var request = http.get(opts, function(response) { + // handle redirections with grace + if (response.headers.location) 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(); }); + readStream.pipe(obj.fs.createWriteStream(filePath)); + }); + } + }); + 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); + } + 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(); + 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); + 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' + } + }; + 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 = []; + var s = require('semver'); + vers.forEach((v) => { + if (s.lt(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]; + }); + }; + + obj.removePlugin = function(id, func) { + parent.db.getPlugin(id, function(err, docs){ + var plugin = docs[0]; + var rimraf = require("rimraf"); + let pluginPath = obj.parent.path.join(obj.pluginPath, plugin.shortName); + rimraf.sync(pluginPath); + parent.db.deletePlugin(id, func); + delete obj.plugins[plugin.shortName]; + obj.parent.updateMeshCore(); + }); + }; + + obj.handleAdminReq = function (req, res, user, serv) { + 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) { + 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; }; \ No newline at end of file diff --git a/plugin_development.md b/plugin_development.md new file mode 100644 index 00000000..d5572255 --- /dev/null +++ b/plugin_development.md @@ -0,0 +1,88 @@ +# Plugin Development + +## Overview + +## Anatomy of a plugin: + + - plugin_name/ + -- config.json + -- plugin_name.js + -- modules_meshcore/ // optional + --- plugin_name.js // optional + +## Plugin Configuration File +A valid JSON object within a file named `config.json` in the root folder of your project. An example: + + { + "name": "Plugin Name", + "shortName": "plugin_name", + "version": "0.0.0", + "author": "Author Name", + "description": "Short Description of the plugin", + "hasAdminPanel": false, + "homepage": "https://www.example.com", + "changelogUrl": "https://raw.githubusercontent.com/User/Project/master/changelog.md", + "configUrl": "https://raw.githubusercontent.com/User/Project/master/config.json", + "downloadUrl": "https://github.com/User/Project/archive/master.zip", + "repository": { + "type": "git", + "url": "https://github.com/User/Project.git" + }, + "versionHistoryUrl": "https://api.github.com/repos/User/Project/tags", + "meshCentralCompat": ">0.4.3" + } + +## Configuration File Properties +| Field | Required | Type | Description +|--|--|--|--| +| name | Yes | string | a human-readable name for the plugin +| shortName | Yes | string | an alphanumeric, unique short identifier for the plugin (will be used to access your functions throughout the project +| version | Yes | string | the current version of the plugin +| author | No | string | the author's name +| description | Yes | string | a short, human-readable description of what the plugin does +| hasAdminPanel | Yes | boolean | `true` or `false`, indicates whether or not the plugin will offer its own administrative interface +| homepage | Yes | string | the URL of the projects homepage +| changelogUrl | Yes | string | the URL to the changelog of the project +| configUrl | Yes | string | the URL to the config.json of the project +| downloadUrl | Yes | string | the URL to a ZIP of the project (used for installation/upgrades) +| repository | Yes | JSON object | contains the following attributes +| repository.type | Yes | string | valid values are `git` and in the future, `npm` will also be supported in the future +| repository.url | Yes | string | the URL to the project's repository +| versionHistoryUrl | No | string | the URL to the project's versions/tags +| meshCentralCompat | Yes | string | the semantic version string of required compatibility with the MeshCentral server + +## Plugin Hooks +These are separated into the following categories depending on the type of functionality the plugin should offer. + +- Web UI, to modify the MeshCentral admin interface +- Back End, to modify core functionality of the server and communicate with the Web UI layer as well as the Mesh Agent (Node) layer to send commands and data +- Mesh Agent (Node), to introduce functionality to each agent + +### Web UI Hooks +`onDeviceRefeshEnd`: called when a device is selected in the MeshCentral web interface +`registerPluginTab`: called when a device is selected in the MeshCentral web interface to register a new tab for plugin data, if required +`onDesktopDisconnect`: called when a remote desktop session is disconnected + +#### Exports +Any function can be exported to the Web UI layer by adding the name of the function to an `exports` array in the plugin object. + +### Back End Hooks +`server_startup`: called once when the server starts (or when the plugin is first installed) +`hook_agentCoreIsStable`: called once when an agent initially checks in +`hook_processAgentData`: called each time an agent transmits data back to the server + +### Mesh Agent +Use of the optional file `plugin_name.js` in the optional folder `modules_meshcore` will include the file in the default meshcore file sent to each endpoint. This is useful to add functionality on each of the endpoints. + +## Structure +Much of MeshCentral revolves around returning objects for your structures, and plugins are no different. Within your plugin you can traverse all the way up to the web server and MeshCentral Server classes to access all the functionality those layers provide. This is done by passing the current object to newly created objects, and assigning that reference to a `parent` variable within that object. + + +## Versioning +Versioning your plugin correctly and consistently is essential to ensure users of your plugin are prompted to upgrade when it is available. Semantic versioning is recommended. + +## Changelog +A changelog is highly recommended so that your users know what's changed since their last version. + +## Sample Plugin +[MeshCentral-Sample](https://github.com/ryanblenis/MeshCentral-Sample) is a simple plugin that, upon disconnecting from remote desktop, prompts the user to enter a manual event (note), pre-filled in with the date and timestamp. \ No newline at end of file diff --git a/public/images/leftbar-64.png b/public/images/leftbar-64.png index 1299fed8..ef07b545 100644 Binary files a/public/images/leftbar-64.png and b/public/images/leftbar-64.png differ diff --git a/public/images/plus32.png b/public/images/plus32.png new file mode 100644 index 00000000..f2e17cce Binary files /dev/null and b/public/images/plus32.png differ diff --git a/public/styles/style.css b/public/styles/style.css index 8eb985b6..e326a87e 100644 --- a/public/styles/style.css +++ b/public/styles/style.css @@ -233,7 +233,7 @@ body { } /* #UserDummyMenuSpan, */ -#MainSubMenuSpan, #MeshSubMenuSpan, #UserSubMenuSpan, #ServerSubMenuSpan, #MainMenuSpan, #MainSubMenu, #MeshSubMenu, #UserSubMenu, #ServerSubMenu, #UserDummyMenu { +#MainSubMenuSpan, #MeshSubMenuSpan, #UserSubMenuSpan, #ServerSubMenuSpan, #MainMenuSpan, #MainSubMenu, #MeshSubMenu, #UserSubMenu, #ServerSubMenu, #UserDummyMenu, #PluginSubMenu { width: 100%; height: 24px; color: white; @@ -244,6 +244,10 @@ body { display: none; } +.menu_stack #PluginSubMenu { + display: none; +} + #MainMenuSpan { display: table; } @@ -1291,6 +1295,17 @@ a { left: 6px; } +.lb7 { + background: url(../images/leftbar-64.png) -382px -2px; + height: 62px; + width: 62px; + cursor: pointer; + border: none; + position: absolute; + top: 6px; + left: 6px; +} + .m0 { background: url(../images/images16.png) -32px 0px; height: 16px; @@ -2563,4 +2578,73 @@ a { padding: 3px; border-radius: 3px; background-color: #DDD; -} \ No newline at end of file +} + +#p7tbl { + width: 100%; + border-collapse: collapse; +} + +#p7tbl th, #p7tbl td { + text-align: left; + padding: 12px; +} + +#p7tbl tr:nth-child(n+2):nth-child(odd) { + background-color: #cfeeff; +} + +#p7tbl .chName { + width: 20%; +} + +#p7tbl .chDescription { + width: 38%; +} + +#p7tbl .chSite { + width: 7%; +} + +#p7tbl .chVersion { + width: 5%; +} + +#p7tbl .chUpgradeAvail { + width: 10%; +} + +#p7tbl .chStatus { + width: 10%; +} + +#p7tbl .chAction { + width: 10%; +} + +.pActDisable, .pActDelete, .pActInstall, .pActUpgrade { + cursor: pointer; +} + +#addPlugin { + background-image: url(../images/plus32.png); + width: 32px; + height: 32px; + float: right; + cursor: pointer; + margin-right: 12px; +} + +#pluginRestartNotice { + width: 40em; + font-weight: bold; + border: 1px solid red; + text-align: center; + padding: 14px; + margin: 50px auto; +} + +.pluginContent { + width: 100%; + height: 100%; +} diff --git a/views/default.handlebars b/views/default.handlebars index 6744695a..25ff03ca 100644 --- a/views/default.handlebars +++ b/views/default.handlebars @@ -57,6 +57,9 @@
Normal Connect
PowerShell Connect
+
@@ -88,6 +91,9 @@ +
@@ -109,6 +115,7 @@ My Files My Users My Server + My Plugins   @@ -156,6 +163,11 @@
+
+ + +
Home
+
@@ -405,6 +417,16 @@
+
 
+ +
NameDescriptionLinkVersionLatest AvailableStatusAction
+ +