├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── daemon.js ├── daemon_test.js ├── duplex.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.4 2 | RUN apk add --update nodejs && chmod 755 /usr/lib/node_modules 3 | COPY . /app 4 | WORKDIR /app 5 | ENV NODE_PATH /usr/lib/node_modules 6 | RUN npm install -g 7 | CMD ["/usr/bin/javascriptd"] 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: dev build push 2 | 3 | dev: build 4 | @docker rm -f javascriptd-dev || true 5 | docker run -it --name javascriptd-dev --publish 8000:8000 progrium/javascriptd 6 | 7 | test: 8 | npm test 9 | 10 | build: 11 | docker build -t progrium/javascriptd . 12 | 13 | push: build 14 | docker push progrium/javascriptd 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # javascriptd 2 | 3 | Node.js powered script execution daemon 4 | 5 | ## Using as runtime base 6 | 7 | Javascriptd is typically run inside Docker. Out of the box it has nearly no Node.js 8 | packages available. This is usually undesirable. So make a runtime image: 9 | 10 | ``` 11 | FROM progrium/javascriptd 12 | RUN npm install -g github circleci bluebird 13 | ``` 14 | 15 | Now running your Javascriptd runtime image, scripts will have these packages 16 | available. 17 | 18 | ## Using outside of Docker 19 | 20 | You can use Javascriptd in development without Docker. Just link and run the 21 | binary. It should not be published to NPM. 22 | 23 | ``` 24 | $ npm link 25 | ... elsewhere ... 26 | $ javascriptd 27 | ``` 28 | 29 | Keep in mind it will have all your global packages available. 30 | 31 | ## Securing Javascriptd 32 | 33 | To keep Javascriptd private, set environment variable `SECRET` and run it behind 34 | SSL. HTTP requests will then require the header `x-runtime-secret`. 35 | 36 | In terms of sandboxing, there are two layers of isolation builtin. 37 | 38 | First, each script call is done in a separate V8 context via 39 | Node's [vm](https://nodejs.org/api/vm.html) module. "Breaking out" requires access 40 | to various modules, which can be imported by `require`. Normally `require` is not 41 | available, but we add it since there is little useful JavaScript that can be 42 | written in an empty context. This [should be secured](https://github.com/progrium/javascriptd/issues/2) 43 | by whitelisting safe Node modules and NPM packages. 44 | 45 | Second, Javascriptd is made to be run inside Docker by a non-root user. This, 46 | combined with extra isolation levels that can be configured via Docker, provides 47 | pretty solid isolation guarantees. Further isolation could be added around Docker 48 | and in the environment Docker is run as needed. 49 | 50 | ## Running scripts 51 | 52 | The Javascriptd daemon exposes a [Duplex](https://github.com/progrium/duplex) JSON-over-WebSocket endpoint. 53 | It has one method: 54 | 55 | ### runtime.execute(script) => results 56 | 57 | #### script 58 | 59 | ``` 60 | type script struct { 61 | code string // script contents 62 | globals object // optional globals 63 | call string // optional name of function to call 64 | caller string // optional caller function code 65 | } 66 | ``` 67 | 68 | `code` is some JS you want to populate a context with and evaluate. `globals` is a object 69 | that's used as the global context object. All keys of `globals` are available 70 | to the script. `call` is the name of a function you want to call. This function 71 | is not called directly, it's called with a caller. 72 | 73 | The default caller looks like `function(callee, cb) { callee(cb); }`, where 74 | `cb` is the callback to return a value, and `callee` is the function identified 75 | by `call`. However, you can override this with `caller` so you can customize 76 | how a function is called and set up more of the context for the call. 77 | 78 | For example, this is a value for `caller` that will initialize a Github client, 79 | authenticate with a token from globals, and call the callee with the Github 80 | object, an event object from globals, and the callback as arguments: 81 | 82 | ``` 83 | (function(callee, cb) { 84 | var github = new require("github")(); 85 | github.authenticate({ 86 | type: "token", 87 | token: secrets.token 88 | }); 89 | callee(github, event, cb); 90 | }) 91 | ``` 92 | 93 | #### result 94 | 95 | ``` 96 | type result struct { 97 | value ? // first argument of cb 98 | console []string // output from console.log 99 | time number // time to complete in milliseconds 100 | } 101 | ``` 102 | 103 | #### errors 104 | 105 | Possible Duplex errors include: 106 | 107 | * `1000` **$exception** There was an exception in the script. 108 | * `1001` **Not implemented** The function in `call` does not exist. 109 | * `1002` **Timeout** The script and call timed out. 110 | 111 | Errors may include a data object: 112 | 113 | ``` 114 | type errorData struct { 115 | stack string // stacktrace if available 116 | console []string // output from console.log 117 | } 118 | ``` 119 | -------------------------------------------------------------------------------- /daemon.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var http = require('http'); 3 | var vm = require('vm'); 4 | var crypto = require('crypto'); 5 | var util = require('util'); 6 | 7 | var ws = require("nodejs-websocket"); 8 | var {duplex} = require("./duplex.js"); 9 | 10 | var port = process.env["PORT"] || "8765"; 11 | var timeout = process.env["TIMEOUT"] || "3000"; 12 | var secret = process.env["SECRET"]; 13 | 14 | function log() { 15 | if (process.env["NOLOGS"] == "true") return; 16 | var args = Array.prototype.slice.call(arguments); 17 | args.unshift(Date.now()); 18 | console.log(...args); 19 | } 20 | 21 | var rpc = new duplex.RPC(duplex.JSON); 22 | 23 | rpc.register("runtime.execute", function(ch) { 24 | ch.onrecv = function(err, script) { 25 | var timeoutHandle; 26 | const logs = []; 27 | const hash = crypto 28 | .createHash('md5') 29 | .update(script.code) 30 | .digest("hex"); 31 | try { 32 | const sandbox = (script.globals || {}); 33 | const defaultCaller = (callee, cb) => callee(cb); 34 | 35 | sandbox._script = script.code; 36 | sandbox._call = script.call; 37 | sandbox.require = require; 38 | sandbox.console = { 39 | log: function() { 40 | var args = Array.prototype.slice.call(arguments); 41 | logs.push(args.join(" ")); 42 | } 43 | } 44 | 45 | var startTime = Date.now(); 46 | timeoutHandle = setTimeout(function() { 47 | log(hash, script.call, "Timeout"); 48 | ch.senderr(1002, "Timeout") 49 | }, 3000); 50 | vm.createContext(sandbox); 51 | vm.runInContext(script.code, sandbox, {timeout: timeout}); 52 | if (sandbox[script.call] === undefined) { 53 | clearTimeout(timeoutHandle); 54 | ch.senderr(1001, "Not implemented") 55 | return 56 | } 57 | var caller = vm.runInContext(script.caller || defaultCaller, sandbox, {timeout: timeout}); 58 | caller(sandbox[script.call], function(value) { 59 | clearTimeout(timeoutHandle); 60 | var time = Date.now() - startTime; 61 | log(hash, script.call, time); 62 | ch.send({"value": value || null, "console": logs, "time": time}); 63 | }); 64 | } catch (e) { 65 | var name; 66 | if (typeof e == "string") { 67 | name = e; 68 | } else { 69 | name = e.name; 70 | } 71 | if (timeoutHandle !== undefined) { 72 | clearTimeout(timeoutHandle); 73 | } 74 | log(hash, script.call, name); 75 | ch.senderr(1000, name, {stack: e.stack, console: logs}); 76 | } 77 | } 78 | }) 79 | 80 | ws.createServer(function (conn) { 81 | if (secret !== "") { 82 | if (conn.headers["x-runtime-secret"] != secret) { 83 | conn.close(); 84 | } 85 | } 86 | rpc.accept(duplex.wrap["nodejs-websocket"](conn)) 87 | }).listen(port); 88 | 89 | console.log(`Serving engine on ${port}...`) 90 | -------------------------------------------------------------------------------- /daemon_test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var ws = require("nodejs-websocket"); 3 | var {duplex} = require("./duplex.js"); 4 | 5 | process.env["NOLOGS"] = process.env["NOLOGS"] || "true"; 6 | process.env["PORT"] = process.env["PORT"] || "8765"; 7 | require("./daemon.js"); 8 | 9 | const errNotImplemented = {code: 1001, message: "Not implemented"}; 10 | const errTimeout = {code: 1002, message: "Timeout"}; 11 | 12 | var execTests = [ 13 | {name: "notImplemented", 14 | script: {code: ""}, 15 | error: errNotImplemented 16 | }, 17 | {name: "callbackNoArg", 18 | script: {code: (function callbackNoArg(cb) { 19 | cb(); 20 | }).toString()} 21 | }, 22 | {name: "callbackIgnoreMultipleArgs", 23 | script: {code: (function callbackIgnoreMultipleArgs(cb) { 24 | cb(1,2,3); 25 | }).toString()}, 26 | result: {value: 1} 27 | }, 28 | {name: "callbackBool", 29 | script: {code: (function callbackBool(cb) { 30 | cb(true); 31 | }).toString()}, 32 | result: {value: true} 33 | }, 34 | {name: "callbackString", 35 | script: {code: (function callbackString(cb) { 36 | cb("foobar"); 37 | }).toString()}, 38 | result: {value: "foobar"} 39 | }, 40 | {name: "callbackArray", 41 | script: {code: (function callbackArray(cb) { 42 | cb([3,2,1]); 43 | }).toString()}, 44 | result: {value: [3,2,1]} 45 | }, 46 | {name: "callbackObject", 47 | script: {code: (function callbackObject(cb) { 48 | cb({foobar: "foobar"}); 49 | }).toString()}, 50 | result: {value: {foobar: "foobar"}} 51 | }, 52 | {name: "consoleLogs", 53 | script: {code: (function consoleLogs(cb) { 54 | var baz = "baz"; 55 | console.log("foo", "bar", baz); 56 | console.log("second log") 57 | cb(); 58 | }).toString()}, 59 | result: {console: ["foo bar baz", "second log"]} 60 | } 61 | // TODO: exceptions 62 | // TODO: timeouts 63 | // TODO: globals 64 | // TODO: callers 65 | ]; 66 | 67 | describe('runtime', function() { 68 | describe('#execute()', function() { 69 | execTests.forEach(function(test) { 70 | it(test.name, function(done) { 71 | var rpc = new duplex.RPC(duplex.JSON); 72 | var conn = ws.connect("ws://localhost:8000/", {}, () => { 73 | rpc.handshake(duplex.wrap["nodejs-websocket"](conn), (peer) => { 74 | // fill in structure we want/expect but leave out of tests 75 | // to keep them easier to read 76 | test.script.call = test.script.call || test.name; 77 | test.error = test.error || null; 78 | if (test.error == null) { 79 | test.result = test.result || {}; 80 | test.result.console = test.result.console || []; 81 | test.result.value = test.result.value || null; 82 | } 83 | peer.call("runtime.execute", test.script, function(err, result) { 84 | if (result !== undefined) { 85 | delete result.time; // ignore time 86 | } 87 | assert.deepEqual({error: err, result: result}, {error: test.error, result: test.result}); 88 | done(); 89 | }); 90 | }); 91 | }); 92 | }); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /duplex.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.10.0 2 | (function() { 3 | var UUIDv4, assert, duplex, errorMsg, replyMsg, requestMsg, 4 | bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; 5 | 6 | assert = function(description, condition) { 7 | if (condition == null) { 8 | condition = false; 9 | } 10 | if (!condition) { 11 | throw Error("Assertion: " + description); 12 | } 13 | }; 14 | 15 | duplex = { 16 | version: "0.1.0", 17 | protocol: { 18 | name: "SIMPLEX", 19 | version: "1.0" 20 | }, 21 | request: "req", 22 | reply: "rep", 23 | handshake: { 24 | accept: "+OK" 25 | }, 26 | JSON: ["json", JSON.stringify, JSON.parse], 27 | wrap: { 28 | "websocket": function(ws) { 29 | var conn; 30 | conn = { 31 | send: function(msg) { 32 | return ws.send(msg); 33 | }, 34 | close: function() { 35 | return ws.close(); 36 | } 37 | }; 38 | ws.onmessage = function(event) { 39 | return conn.onrecv(event.data); 40 | }; 41 | return conn; 42 | }, 43 | "nodejs-websocket": function(ws) { 44 | var conn; 45 | conn = { 46 | send: function(msg) { 47 | return ws.send(msg); 48 | }, 49 | close: function() { 50 | return ws.close(); 51 | } 52 | }; 53 | ws.on("text", function(msg) { 54 | return conn.onrecv(msg); 55 | }); 56 | return conn; 57 | } 58 | } 59 | }; 60 | 61 | requestMsg = function(payload, method, id, more, ext) { 62 | var msg; 63 | msg = { 64 | type: duplex.request, 65 | method: method, 66 | payload: payload 67 | }; 68 | if (id != null) { 69 | msg.id = id; 70 | } 71 | if (more === true) { 72 | msg.more = more; 73 | } 74 | if (ext != null) { 75 | msg.ext = ext; 76 | } 77 | return msg; 78 | }; 79 | 80 | replyMsg = function(id, payload, more, ext) { 81 | var msg; 82 | msg = { 83 | type: duplex.reply, 84 | id: id, 85 | payload: payload 86 | }; 87 | if (more === true) { 88 | msg.more = more; 89 | } 90 | if (ext != null) { 91 | msg.ext = ext; 92 | } 93 | return msg; 94 | }; 95 | 96 | errorMsg = function(id, code, message, data, ext) { 97 | var msg; 98 | msg = { 99 | type: duplex.reply, 100 | id: id, 101 | error: { 102 | code: code, 103 | message: message 104 | } 105 | }; 106 | if (data != null) { 107 | msg.error.data = data; 108 | } 109 | if (ext != null) { 110 | msg.ext = ext; 111 | } 112 | return msg; 113 | }; 114 | 115 | UUIDv4 = function() { 116 | var d, ref; 117 | d = new Date().getTime(); 118 | if (typeof (typeof window !== "undefined" && window !== null ? (ref = window.performance) != null ? ref.now : void 0 : void 0) === "function") { 119 | d += performance.now(); 120 | } 121 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 122 | var r; 123 | r = (d + Math.random() * 16) % 16 | 0; 124 | d = Math.floor(d / 16); 125 | if (c !== 'x') { 126 | r = r & 0x3 | 0x8; 127 | } 128 | return r.toString(16); 129 | }); 130 | }; 131 | 132 | duplex.RPC = (function() { 133 | function RPC(codec) { 134 | this.codec = codec; 135 | this.encode = this.codec[1]; 136 | this.decode = this.codec[2]; 137 | this.registered = {}; 138 | } 139 | 140 | RPC.prototype.register = function(method, handler) { 141 | return this.registered[method] = handler; 142 | }; 143 | 144 | RPC.prototype.unregister = function(method) { 145 | return delete this.registered[method]; 146 | }; 147 | 148 | RPC.prototype.registerFunc = function(method, func) { 149 | return this.register(method, function(ch) { 150 | return ch.onrecv = function(err, args) { 151 | return func(args, (function(reply, more) { 152 | if (more == null) { 153 | more = false; 154 | } 155 | return ch.send(reply, more); 156 | }), ch); 157 | }; 158 | }); 159 | }; 160 | 161 | RPC.prototype.callbackFunc = function(func) { 162 | var name; 163 | name = "_callback." + UUIDv4(); 164 | this.registerFunc(name, func); 165 | return name; 166 | }; 167 | 168 | RPC.prototype._handshake = function() { 169 | var p; 170 | p = duplex.protocol; 171 | return p.name + "/" + p.version + ";" + this.codec[0]; 172 | }; 173 | 174 | RPC.prototype.handshake = function(conn, onready) { 175 | var peer; 176 | peer = new duplex.Peer(this, conn, onready); 177 | conn.onrecv = function(data) { 178 | if (data[0] === "+") { 179 | conn.onrecv = peer.onrecv; 180 | return peer._ready(peer); 181 | } else { 182 | return assert("Bad handshake: " + data); 183 | } 184 | }; 185 | conn.send(this._handshake()); 186 | return peer; 187 | }; 188 | 189 | RPC.prototype.accept = function(conn, onready) { 190 | var peer; 191 | peer = new duplex.Peer(this, conn, onready); 192 | conn.onrecv = function(data) { 193 | conn.onrecv = peer.onrecv; 194 | conn.send(duplex.handshake.accept); 195 | return peer._ready(peer); 196 | }; 197 | return peer; 198 | }; 199 | 200 | return RPC; 201 | 202 | })(); 203 | 204 | duplex.Peer = (function() { 205 | function Peer(rpc, conn1, onready1) { 206 | this.rpc = rpc; 207 | this.conn = conn1; 208 | this.onready = onready1 != null ? onready1 : function() {}; 209 | this.onrecv = bind(this.onrecv, this); 210 | assert("Peer expects an RPC", this.rpc.constructor.name === "RPC"); 211 | assert("Peer expects a connection", this.conn != null); 212 | this.lastId = 0; 213 | this.ext = null; 214 | this.reqChan = {}; 215 | this.repChan = {}; 216 | } 217 | 218 | Peer.prototype._ready = function(peer) { 219 | return this.onready(peer); 220 | }; 221 | 222 | Peer.prototype.close = function() { 223 | return this.conn.close(); 224 | }; 225 | 226 | Peer.prototype.call = function(method, args, callback) { 227 | var ch; 228 | ch = new duplex.Channel(this, duplex.request, method, this.ext); 229 | if (callback != null) { 230 | ch.id = ++this.lastId; 231 | ch.onrecv = callback; 232 | this.repChan[ch.id] = ch; 233 | } 234 | return ch.send(args); 235 | }; 236 | 237 | Peer.prototype.open = function(method, callback) { 238 | var ch; 239 | ch = new duplex.Channel(this, duplex.request, method, this.ext); 240 | ch.id = ++this.lastId; 241 | this.repChan[ch.id] = ch; 242 | if (callback != null) { 243 | ch.onrecv = callback; 244 | } 245 | return ch; 246 | }; 247 | 248 | Peer.prototype.onrecv = function(frame) { 249 | var ch, msg, ref, ref1; 250 | if (frame === "") { 251 | return; 252 | } 253 | msg = this.rpc.decode(frame); 254 | switch (msg.type) { 255 | case duplex.request: 256 | if (this.reqChan[msg.id] != null) { 257 | ch = this.reqChan[msg.id]; 258 | if (msg.more === false) { 259 | delete this.reqChan[msg.id]; 260 | } 261 | } else { 262 | ch = new duplex.Channel(this, duplex.reply, msg.method); 263 | if (msg.id !== void 0) { 264 | ch.id = msg.id; 265 | if (msg.more === true) { 266 | this.reqChan[ch.id] = ch; 267 | } 268 | } 269 | assert("Method not registerd", this.rpc.registered[msg.method] != null); 270 | this.rpc.registered[msg.method](ch); 271 | } 272 | if (msg.ext != null) { 273 | ch.ext = msg.ext; 274 | } 275 | return ch.onrecv(null, msg.payload, msg.more); 276 | case duplex.reply: 277 | if (msg.error != null) { 278 | if ((ref = this.repChan[msg.id]) != null) { 279 | ref.onrecv(msg.error); 280 | } 281 | return delete this.repChan[msg.id]; 282 | } else { 283 | if ((ref1 = this.repChan[msg.id]) != null) { 284 | ref1.onrecv(null, msg.payload, msg.more); 285 | } 286 | if (msg.more === false) { 287 | return delete this.repChan[msg.id]; 288 | } 289 | } 290 | break; 291 | default: 292 | return assert("Invalid message"); 293 | } 294 | }; 295 | 296 | return Peer; 297 | 298 | })(); 299 | 300 | duplex.Channel = (function() { 301 | function Channel(peer1, type, method1, ext1) { 302 | this.peer = peer1; 303 | this.type = type; 304 | this.method = method1; 305 | this.ext = ext1; 306 | assert("Channel expects Peer", this.peer.constructor.name === "Peer"); 307 | this.id = null; 308 | this.onrecv = function() {}; 309 | } 310 | 311 | Channel.prototype.call = function(method, args, callback) { 312 | var ch; 313 | ch = this.peer.open(method, callback); 314 | ch.ext = this.ext; 315 | return ch.send(args); 316 | }; 317 | 318 | Channel.prototype.close = function() { 319 | return this.peer.close(); 320 | }; 321 | 322 | Channel.prototype.open = function(method, callback) { 323 | var ch; 324 | ch = this.peer.open(method, callback); 325 | ch.ext = this.ext; 326 | return ch; 327 | }; 328 | 329 | Channel.prototype.send = function(payload, more) { 330 | if (more == null) { 331 | more = false; 332 | } 333 | switch (this.type) { 334 | case duplex.request: 335 | return this.peer.conn.send(this.peer.rpc.encode(requestMsg(payload, this.method, this.id, more, this.ext))); 336 | case duplex.reply: 337 | return this.peer.conn.send(this.peer.rpc.encode(replyMsg(this.id, payload, more, this.ext))); 338 | default: 339 | return assert("Bad channel type"); 340 | } 341 | }; 342 | 343 | Channel.prototype.senderr = function(code, message, data) { 344 | assert("Not reply channel", this.type === duplex.reply); 345 | return this.peer.conn.send(this.peer.rpc.encode(errorMsg(this.id, code, message, data, this.ext))); 346 | }; 347 | 348 | return Channel; 349 | 350 | })(); 351 | 352 | if (typeof window !== "undefined" && window !== null) { 353 | window.duplex = duplex; 354 | } else { 355 | exports.duplex = duplex; 356 | } 357 | 358 | }).call(this); 359 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "javascriptd", 3 | "version": "0.1.0", 4 | "description": "Node.js powered script execution daemon", 5 | "main": "daemon.js", 6 | "scripts": { 7 | "test": "mocha *_test.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/progrium/javascriptd.git" 12 | }, 13 | "author": "Jeff Lindsay (http://progrium.com)", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/progrium/javascriptd/issues" 17 | }, 18 | "homepage": "https://github.com/progrium/javascriptd#readme", 19 | "bin": { 20 | "javascriptd": "./daemon.js" 21 | }, 22 | "dependencies": { 23 | "nodejs-websocket": "*" 24 | }, 25 | "devDependencies": { 26 | "mocha": "^3.0.2" 27 | } 28 | } 29 | --------------------------------------------------------------------------------