/** * Copyright 2013-2017 Zaid Abdulla * * This file is part of GenieACS. * * GenieACS is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * GenieACS is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with GenieACS. If not, see . */ "use strict"; const vm = require("vm"); const seedrandom = require("seedrandom"); const common = require("./common"); const device = require("./device"); const extensions = require("./extensions"); const logger = require("./logger"); // Used for throwing to exit user script and commit const COMMIT = Symbol(); // Used to execute extensions and restart const EXT = Symbol(); const context = vm.createContext(); let state; class SandboxDate { constructor(arg) { if (arguments.length) return new (Function.prototype.bind.apply(Date, arguments)); return new Date(state.sessionContext.timestamp); } static now() { return state.sessionContext.timestamp; } }; function random() { if (!state.rng) state.rng = seedrandom(sessionContext.timestamp); return state.rng(); } class ParameterWrapper { constructor(path, attributes, unpacked, unpackedRevision) { for (let attrName of attributes) { Object.defineProperty(this, attrName, {get: function() { if (state.uncommitted) commit(); if (state.revision !== unpackedRevision) { unpackedRevision = state.revision; unpacked = device.unpack(state.sessionContext.deviceData, path, state.revision + 1); } if (!unpacked.length) return undefined; let attr = state.sessionContext.deviceData.attributes.get(unpacked[0], state.revision + 1)[attrName]; if (!attr) return undefined; return attr[1]; }}); } Object.defineProperty(this, "path", {get: function() { if (state.uncommitted) commit(); if (state.revision !== unpackedRevision) { unpackedRevision = state.revision; unpacked = device.unpack(state.sessionContext.deviceData, path, state.revision + 1); } if (!unpacked.length) return undefined; return unpacked[0].join("."); }}); Object.defineProperty(this, "size", {get: function() { if (state.uncommitted) commit(); if (state.revision !== unpackedRevision) { unpackedRevision = state.revision; unpacked = device.unpack(state.sessionContext.deviceData, path, state.revision + 1); } if (!unpacked.length) return undefined; return unpacked.length; }}); this[Symbol.iterator] = function*() { if (state.uncommitted) commit(); if (state.revision !== unpackedRevision) { unpackedRevision = state.revision; unpacked = device.unpack(state.sessionContext.deviceData, path, state.revision + 1); } for (let p of unpacked) yield new ParameterWrapper(p, attributes, [p], state.revision); } } } function declare(path, timestamps, values) { state.uncommitted = true; if (!timestamps) timestamps = {}; if (!values) values = {}; let parsedPath = common.parsePath(path); let declaration = [parsedPath, 1]; let attrs = {}; for (let attrName in values) { if (!timestamps[attrName]) timestamps[attrName] = 1; if (attrName === "path") declaration[3] = values.path; else { attrs[attrName] = 1; declaration[4] = declaration[4] || {}; if (attrName === "value" && !Array.isArray(values.value)) declaration[4].value = [values.value]; else declaration[4][attrName] = values[attrName]; } } for (let attrName in timestamps) { if (attrName === "path") declaration[1] = timestamps.path; else { attrs[attrName] = 1; declaration[2] = declaration[2] || {}; declaration[2][attrName] = timestamps[attrName]; } } state.declarations.push(declaration); return new ParameterWrapper(parsedPath, Object.keys(attrs)); } function clear(path, timestamp, attributes) { state.uncommitted = true; if (state.revision === state.maxRevision) state.clear.push([common.parsePath(path), timestamp, attributes]); } function commit() { ++ state.revision; state.uncommitted = false; if (state.revision === state.maxRevision + 1) throw COMMIT; else if (state.revision > state.maxRevision + 1) throw new Error("Declare function should not be called from within a try/catch block"); } function ext() { ++ state.extCounter; let extCall = Array.from(arguments).map(String); let key = `${state.revision}: ${JSON.stringify(extCall)}`; if (key in state.sessionContext.extensionsCache) return state.sessionContext.extensionsCache[key]; state.extensions[key] = extCall; throw EXT; } function log(msg, meta) { if (state.revision === state.maxRevision && state.extCounter >= 0) { const details = Object.assign({}, meta, { sessionContext: state.sessionContext, message: `Script: ${msg}` }); delete details.hostname; delete details.pid; delete details.name; delete details.version; delete details.deviceId; delete details.remoteAddress; logger.accessInfo(details); } } Object.defineProperty(context, "Date", {value: SandboxDate}); Object.defineProperty(context, "declare", {value: declare}); Object.defineProperty(context, "clear", {value: clear}); Object.defineProperty(context, "commit", {value: commit}); Object.defineProperty(context, "ext", {value: ext}); Object.defineProperty(context, "log", {value: log}); // Monkey-patch Math.random() to make it deterministic context.random = random; vm.runInContext("Math.random = random;", context); delete context.random; function errorToFault(err) { if (!err) return null; // Trim the stack trace at the self-executing anonymous wrapper function let stackTrimIndex = err.stack.match(/\s+at\s[^\s]+\s+at\s[^\s]+\s\(vm\.js.+\)/).index; return { code: `script.${err.name}`, message: err.message, detail: { name: err.name, message: err.message, stack: err.stack.slice(0, stackTrimIndex) } } } function run(script, globals, sessionContext, startRevision, maxRevision, callback, extCounter = 0) { state = { sessionContext: sessionContext, revision: startRevision, maxRevision: maxRevision, uncommitted: false, declarations: [], extensions: {}, clear: [], rng: null, extCounter: extCounter }; for (let n of Object.keys(context)) delete context[n]; Object.assign(context, globals); let ret, status; try { ret = script.runInContext(context, {displayErrors: false}); status = 0; } catch (err) { if (err === COMMIT) status = 1; else if (err === EXT) status = 2; else return callback(null, errorToFault(err)); } let _state = state; let args = Array.from(arguments); // Need to maintain a counter of ext() calls to avoid calling certain // functions (e.g. log()) multiple times as calling ext() results in // re-execution of script without revision being incremented. args[6] = 0 - (state.extCounter - extCounter); let counter = 3; for (let key of Object.keys(_state.extensions)) { counter += 2; extensions.run(_state.extensions[key], function(err, fault, res) { if (err || fault) { if (counter & 1) callback(err, fault); return counter = 0; } _state.sessionContext.extensionsCache[key] = res; if ((counter -= 2) === 1) if (status === 2) return run.apply(null, args); else return callback(null, null, _state.clear, _state.declarations, status === 0, ret); }); } if ((counter -= 2) === 1) if (status === 2) return run.apply(null, args); else return callback(null, null, _state.clear, _state.declarations, status === 0, ret); } exports.run = run;