diff --git a/gulpfile.js b/gulpfile.js index ec9ca4a3..d11521f0 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -40,7 +40,8 @@ function js(){ "node_modules/jquery-contextmenu/dist/jquery.ui.position.min.js", "node_modules/chart.js/dist/Chart.min.js", "node_modules/masonry-layout/dist/masonry.pkgd.min.js", - 'node_modules/navigo/lib/navigo.min.js' + 'node_modules/navigo/lib/navigo.min.js', + 'src/assets/jquery-resizeable/jquery-ui.min.js' ]) .pipe(minify({ noSource: true diff --git a/node/controllers/textTerminal.controller.js b/node/controllers/textTerminal.controller.js index ba3d000d..ff3a54d1 100644 --- a/node/controllers/textTerminal.controller.js +++ b/node/controllers/textTerminal.controller.js @@ -4,36 +4,19 @@ module.exports = class TextTerminalController { } getNewTerminalProcess = async (req, res) => { - // Create a identifier for the console, this should allow multiple consolses - // per user - let uuid = this._terminals.getInternalUuid(req.body.host, req.body.container, req.query.cols, req.query.rows); - res.json({ processId: uuid }); - res.send(); + // Create a identifier for the console, this should allow multiple consolses + // per user + let terminalId = this._terminals.getNewTerminalId(req.body.user_id, req.body.hostId, req.body.project, req.body.instance, req.body.shell, req.query.cols, req.query.rows); + res.json({ terminalId: terminalId }); + res.send(); } openTerminal = async (socket, req) => { - let host = req.query.hostId, - container = req.query.instance, - uuid = req.query.pid, - shell = req.query.shell, - project = req.query.project; - - await this._terminals.createTerminalIfReq(socket, host, project, container, uuid, shell) - - //NOTE When user inputs from browser - socket.on("message", (msg) => { - let resizeCommand = msg.match(/resize-window\:cols=([0-9]+)&rows=([0-9]+)/); - if (resizeCommand) { - this._terminals.resize(uuid, resizeCommand[1], resizeCommand[2]) - } else { - this._terminals.sendToTerminal(uuid, msg); - } - }); - socket.on('error', () => { - console.log("socket error"); - }); - socket.on('close', () => { - this._terminals.close(uuid); - }); + this._terminals.proxyTerminal( + req.query.terminalId, + socket + ).catch(function(e){ + console.log(e); + }) } } diff --git a/node/events.js b/node/events.js index 637ccded..f9a926a2 100644 --- a/node/events.js +++ b/node/events.js @@ -58,6 +58,12 @@ app.use(authenticateExpressRoute.authenticateReq); // REGISTER HTTP ENDPOINTS app.post('/terminals', textTerminalController.getNewTerminalProcess); +app.post('/terminals/checkStatus', function(req, res) { + let status = terminals.checkStatus(req.body.user_id, req.body.terminalId); + res.json(status); + res.send(); +}); + // REGISTER WEBSOCKET ENDPOINTS app.ws('/node/terminal/', vgaTerminalsController.openTerminal) app.ws('/node/operations', hostEventsController.addClientSocket) diff --git a/node/services/terminals.service.js b/node/services/terminals.service.js index 47b201d6..9c82f92b 100644 --- a/node/services/terminals.service.js +++ b/node/services/terminals.service.js @@ -1,294 +1,315 @@ var WebSocket = require('ws'); -var internalUuidv1 = require('uuid/v1'); var http = require('http'); var https = require('https'); +var internalUuidv1 = require('uuid/v1'); module.exports = class Terminals { - constructor(hosts) { - this._hosts = hosts - this.activeTerminals = {}; - this.internalUuidMap = {}; - } - - getInternalUuid(host, container, cols, rows) { - let key = `${host}.${container}`; - let knownInternalId = this.internalUuidMap.hasOwnProperty(key) ? this.internalUuidMap[key].uuid : false - - if (knownInternalId && this.activeTerminals.hasOwnProperty(knownInternalId) && this.activeTerminals[knownInternalId].closing !== true) { - return this.internalUuidMap[key].uuid - } - let internalUuid = internalUuidv1(); - - this.internalUuidMap[key] = { - "uuid": internalUuid, - "cols": cols, - "rows": rows - }; - return internalUuid; - } - - sendToTerminal(internalUuid, msg) { - if (this.activeTerminals[internalUuid] == undefined) { - return; + constructor(hosts) { + this.hosts = hosts; + this._terminalDetails = {} } - this.activeTerminals[internalUuid][0].send( - msg, - { - binary: true, - }, - () => {} - ); - } - - resize(internalUuid, cols, rows) { - if (this.activeTerminals[internalUuid] == undefined) { - return; + getNewTerminalId(userId, hostId, project, instance, shell) { + let terminalId = internalUuidv1(); + this._terminalDetails[terminalId] = { + hostId: hostId, + project: project, + instance: instance, + userId: userId, + clientSocket: null, + controlSocket: null, + opSocket: null + } + return terminalId; } - let key = Object.keys(this.internalUuidMap).filter((key) => {return this.internalUuidMap[key].uuid === internalUuid})[0]; - - this.internalUuidMap[key].cols = cols - this.internalUuidMap[key].rows = rows - - this.activeTerminals[internalUuid]["control"].send( - JSON.stringify({ - command: "window-resize", - args: { - height: `${parseInt(rows)}`, - width: `${parseInt(cols)}` - } - }), - { - binary: true, - }, - () => {} - ); - } - - close(internalUuid, exitCommand = "exit\r\n") { - Object.keys(this.internalUuidMap).forEach(key =>{ - if(this.internalUuidMap[key].uuid == internalUuid){ - delete this.internalUuidMap[key]; - return false; - } - }); - if (this.activeTerminals[internalUuid] == undefined) { - return; + + checkStatus(userId, terminalId){ + let response = {exists: false, inUse: false} + if(!this._terminalDetails.hasOwnProperty(terminalId)){ + return response + } + let term = this._terminalDetails[terminalId] + if(term.userId !== userId){ + return response + } + response.exists = true + response.inUse = this._terminalDetails[terminalId].clientSocket == null; + return response } - this.activeTerminals[internalUuid].closing = true - this.activeTerminals[internalUuid][0].send( - exitCommand, - { binary: true }, - () => { - // NOTE This timeout is required to stop LXD panicing (bug reported) - setTimeout(()=>{ - this.activeTerminals[internalUuid][0].close(); - this.activeTerminals[internalUuid]["control"].close(); - delete this.activeTerminals[internalUuid]; - }, 100) - } - ); - } - - closeAll() { - let keys = Object.keys(this.activeTerminals); - - for (let i = 0; i < keys.length; i++) { - this.close(keys[i]); + proxyTerminal = async ( + terminalId, + clientSocket, + allowUserInput = true, + callbacks = {} + ) => { + return new Promise(async (resolve, reject) => { + console.log(this._terminalDetails); + if (!this._terminalDetails.hasOwnProperty(terminalId)) { + console.error("trying to access terminal terminalId that doesn't exist"); + return false; + } + if (this._terminalDetails[terminalId].opSocket !== null) { + this._configureClientSocket(clientSocket, terminalId, allowUserInput) + + this._terminalDetails[terminalId].opSocket.on('error', error => + console.log(error) + ); + + this._terminalDetails[terminalId].opSocket.on("message", (data) => { + const buf = Buffer.from(data); + data = buf.toString(); + this._terminalDetails[terminalId].lastMessage = data + if (clientSocket.readyState == 1) { + clientSocket.send(data); + } + }); + + resolve(true); + return; + } + + let host = await this.hosts.getHost(this._terminalDetails[terminalId].hostId); + let project = this._terminalDetails[terminalId].project + let instance = this._terminalDetails[terminalId].instance + let shell = this._terminalDetails[terminalId].shell + + this._openLxdSockets(host, project, instance, shell) + .then(lxdResponse => { + const wsoptions = { + cert: host.cert, + key: host.key, + rejectUnauthorized: false, + }; + + let proto = 'wss://'; + let target = `${host.hostWithOutProtoOrPort}:${host.port}` + + let path = lxdResponse.operation + '/websocket?secret='; + let termSocketPath = path + lxdResponse.metadata.metadata.fds['0']; + let controlSocketPath = path + lxdResponse.metadata.metadata.fds['control']; + + if (host.socketPath !== null) { + proto = 'ws+unix://' + target = host.socketPath + // Unix sockets need ":" between file path and http path + termSocketPath = ":" + termSocketPath; + // Unix sockets need ":" between file path and http path + controlSocketPath = ":" + controlSocketPath; + } + + let url = `${proto}${target}`; + let lxdWs = new WebSocket(`${url}${termSocketPath}`, wsoptions); + let controlSocket = new WebSocket(`${url}${controlSocketPath}`, wsoptions); + + controlSocket.on("close", () => { + //NOTE If you try to connect to a "bash" shell on an alpine instance + // it "slienty" fails only closing the control clientSocket so we need + // to tidy up the remaining sockets + lxdWs.close() + clientSocket.close() + }); + + this._configureClientSocket(clientSocket, terminalId, allowUserInput) + + lxdWs.on('error', error => console.log(error)); + + lxdWs.on('message', data => { + const buf = Buffer.from(data); + data = buf.toString(); + + if (typeof callbacks.formatServerResponse === "function") { + data = callbacks.formatServerResponse(data) + } + + if (clientSocket.readyState == 1) { + clientSocket.send(data); + } + + if (typeof callbacks.afterSeverResponeSent === "function") { + callbacks.afterSeverResponeSent(data) + } + }); + + this._terminalDetails[terminalId].controlSocket = controlSocket + this._terminalDetails[terminalId].opSocket = lxdWs + + resolve(true); + }) + .catch((e) => { + reject(e); + }); + }); } - this.activeTerminals = {}; - } - - createTerminalIfReq = async( - socket, - host, - project, - container, - internalUuid = null, - shell = null, - callbacks = {}) => { - let hostDetails = await this._hosts.getHost(host) - return new Promise((resolve, reject) => { - if (this.activeTerminals[internalUuid] !== undefined) { - this.activeTerminals[internalUuid][0].on('error', error => - console.log(error) - ); + sendToTerminal(terminalId, msg) { + if (this._terminalDetails.hasOwnProperty(terminalId) === false) { + return; + } - this.activeTerminals[internalUuid][0].on("message", (data) => { - const buf = Buffer.from(data); - data = buf.toString(); - if(socket.readyState == 1){ - socket.send(data); - } + this._terminalDetails[terminalId].opSocket.send( + msg, { + binary: true, + }, + () => {} + ); + } - }); + close(terminalId, exitCommand = "exit\r\n", gracePeriod = false) { + if (this._terminalDetails.hasOwnProperty(terminalId) === false) { + return; + } - this.sendToTerminal(internalUuid, '\n'); - resolve(true); - return; - } - - let cols = this.internalUuidMap[`${host}.${container}`].cols; - let rows = this.internalUuidMap[`${host}.${container}`].rows; - - if (this.internalUuidMap.hasOwnProperty(`${host}.${container}`)) { - cols = this.internalUuidMap[`${host}.${container}`].cols - rows = this.internalUuidMap[`${host}.${container}`].rows - } - - this.openLxdOperation(hostDetails, project, container, shell, cols, rows) - .then(openResult => { - // If the server dies but there are active clients they will re-connect - // with their process-id but it wont be in the internalUuidMap - // so we need to re add it - if (!this.internalUuidMap.hasOwnProperty(`${host}.${container}`)) { - this.internalUuidMap[`${host}.${container}`] = { - "uuid": internalUuid, - cols: null, - rows: null, - }; - } + this._terminalDetails[terminalId].clientSocket.close(); + this._terminalDetails[terminalId].clientSocket = null; - const wsoptions = { - cert: hostDetails.cert, - key: hostDetails.key, - rejectUnauthorized: false, - }; + let _postExitCommandSend = ()=>{ + // NOTE This timeout is required to stop LXD panicing (bug reported) + setTimeout(() => { + delete this._terminalDetails[terminalId]; + }, 100) + } - let proto = 'wss://'; - let target = `${hostDetails.hostWithOutProtoOrPort}:${hostDetails.port}` + if(gracePeriod){ + setTimeout(()=>{ + if(this._terminalDetails.hasOwnProperty(terminalId) && this._terminalDetails[terminalId].clientSocket == null){ + this._closeLxdSockets(terminalId, exitCommand, _postExitCommandSend) + } + }, (1000 * 60) * 5) // Wait 5 minutes before closing sockets with LXD) + }else{ + this._closeLxdSockets(terminalId, exitCommand, _postExitCommandSend) + } + } - let path = openResult.operation + '/websocket?secret='; - let termSocketPath = path + openResult.metadata.metadata.fds['0']; - let controlSocketPath = path + openResult.metadata.metadata.fds['control']; + closeAll() { + let keys = Object.keys(this._terminalDetails); - if(hostDetails.socketPath !== null){ - proto = 'ws+unix://' - target = hostDetails.socketPath - termSocketPath = ":" + termSocketPath; // Unix sockets need ":" between file path and http path - controlSocketPath = ":" + controlSocketPath; // Unix sockets need ":" between file path and http path + for (let i = 0; i < keys.length; i++) { + this.close(keys[i]); } - let url = `${proto}${target}`; - let lxdWs = new WebSocket(`${url}${termSocketPath}`, wsoptions); - let controlSocket = new WebSocket(`${url}${controlSocketPath}`, wsoptions); + this._terminalDetails = {}; + } - controlSocket.on("close", ()=>{ - //NOTE If you try to connect to a "bash" shell on an alpine instance - // it "slienty" fails only closing the control socket so we need - // to tidy up the remaining sockets - lxdWs.close() - socket.close() - }); + _closeLxdSockets(terminalId, exitCommand, exitCommandSentCallback){ + this._terminalDetails[terminalId].opSocket.close(); + this._terminalDetails[terminalId].controlSocket.close(); + this._terminalDetails[terminalId].opSocket.send(exitCommand, {binary: true}, exitCommandSentCallback); + } - lxdWs.on('error', error => console.log(error)); + _openLxdSockets(host, project, instance, shell, depth = 0) { + return new Promise((resolve, reject) => { + if (depth >= 5) { + return reject(new Error("Reached max terminal connect retries")) + } + + let execOptions = this._createLxdReqOpts(host, project, instance); + + let data = JSON.stringify(this._createLxdReqBody(shell)) + + const callback = res => { + res.setEncoding('utf8'); + let chunks = []; + res.on('data', function(data) { + chunks.push(data); + }).on('end', function() { + resolve(JSON.parse(chunks.join(''))) + }).on('error', function(data) { + this._openLxdSockets(host, terminalId, depth + 1) + }); + }; - lxdWs.on('message', data => { - const buf = Buffer.from(data); - data = buf.toString(); + if (host.socketPath == null) { + const clientRequest = https.request(execOptions, callback); + clientRequest.write(data) + clientRequest.end(); + } else { + const clientRequest = http.request(execOptions, callback); + clientRequest.write(data) + clientRequest.end(); + } + }) + } - if(typeof callbacks.formatServerResponse === "function"){ - data = callbacks.formatServerResponse(data) - } + _configureClientSocket(clientSocket, terminalId, allowInput = true) { + this._terminalDetails[terminalId].clientSocket = clientSocket + + //NOTE When user inputs from browser + clientSocket.on("message", (msg) => { + let resizeCommand = msg.match(/resize-window\:cols=([0-9]+)&rows=([0-9]+)/); + if (resizeCommand) { + this._resizeOnLxd(terminalId, resizeCommand[1], resizeCommand[2]) + } else if (allowInput) { + this.sendToTerminal(terminalId, msg); + } + }); - if(socket.readyState == 1){ - socket.send(data); - } + clientSocket.on('error', () => { + console.log("socket error"); + }); - if(typeof callbacks.afterSeverResponeSent === "function"){ - callbacks.afterSeverResponeSent(data) - } - }); + clientSocket.on('close', () => { + this.close(terminalId, "exit\r\n", true); + }); + } - this.activeTerminals[internalUuid] = { - 0: lxdWs, - "control": controlSocket - }; + _resizeOnLxd(terminalId, cols, rows) { + if (this._terminalDetails.hasOwnProperty(terminalId) === false) { + return; + } - resolve(true); - }) - .catch((e) => { - reject(e); - }); - }); - } - - openLxdOperation(hostDetails, project, container, shell, cols, rows, depth = 0) { - return new Promise((resolve, reject) => { - if(depth >= 5){ - return reject(new Error("Reached max terminal connect retries")) - } - let execOptions = this.createExecOptions(hostDetails, project, container); - - let data = JSON.stringify(this.getExecBody(shell, cols, rows)) - - const callback = res => { - res.setEncoding('utf8'); - let chunks = []; - res.on('data', function(data) { - chunks.push(data); - }).on('end', function() { - resolve(JSON.parse(chunks.join(''))) - }).on('error', function(data){ - this.openLxdOperation(hostDetails, project, container, shell, cols, rows, depth + 1) - }); - }; - - if(hostDetails.socketPath == null){ - const clientRequest = https.request(execOptions, callback); - clientRequest.write(data) - clientRequest.end(); - }else{ - const clientRequest = http.request(execOptions, callback); - clientRequest.write(data) - clientRequest.end(); - } - }) - } - - getExecBody(toUseShell = null, cols, rows) { - let shell = ['bash']; - - if (typeof toUseShell == 'string' && toUseShell !== '') { - shell = [toUseShell]; + this._terminalDetails[terminalId].controlSocket.send( + JSON.stringify({ + command: "window-resize", + args: { + height: `${parseInt(rows)}`, + width: `${parseInt(cols)}` + } + }), { + binary: true, + }, + () => {} + ); } - return { - command: shell, - environment: { - HOME: '/root', - TERM: 'xterm', - USER: 'root', - }, - 'wait-for-websocket': true, - interactive: true, - height: parseInt(rows), - width: parseInt(cols) - }; - } - - createExecOptions(hostDetails, project, container) { - let url = hostDetails.supportsVms ? 'instances' : 'containers'; - - const options = { - method: 'POST', - path: `/1.0/${url}/${container}/exec?project=${project}`, - cert: hostDetails.cert, - key: hostDetails.key, - rejectUnauthorized: false, - json: true - }; - - if(hostDetails.socketPath == null){ - options.host = hostDetails.hostWithOutProtoOrPort - options.port = hostDetails.port - }else{ - options.socketPath = hostDetails.socketPath + _createLxdReqBody(toUseShell = null) { + let shell = ['bash']; + + if (typeof toUseShell == 'string' && toUseShell !== '') { + shell = [toUseShell]; + } + + return { + command: shell, + environment: { + HOME: '/root', + TERM: 'xterm', + USER: 'root', + }, + 'wait-for-websocket': true, + interactive: true + }; } - return options; + _createLxdReqOpts(host, project, instance) { + let url = host.supportsVms ? 'instances' : 'containers'; + const options = { + method: 'POST', + path: `/1.0/${url}/${instance}/exec?project=${project}`, + cert: host.cert, + key: host.key, + rejectUnauthorized: false, + json: true + }; + + if (host.socketPath == null) { + options.host = host.hostWithOutProtoOrPort + options.port = host.port + } else { + options.socketPath = host.socketPath + } - } + return options; + + } }; diff --git a/src/assets/dist/external.dist.js b/src/assets/dist/external.dist.js index fdde20ba..cef43126 100644 --- a/src/assets/dist/external.dist.js +++ b/src/assets/dist/external.dist.js @@ -13,4 +13,5 @@ define("ace/mode/yaml_highlight_rules",["require","exports","module","ace/lib/oo !function(t){"function"==typeof define&&define.amd?define(["jquery"],t):t(jQuery)}(function(t){t.ui=t.ui||{},t.ui.version="1.12.1",function(){function i(t,i,o){return[parseFloat(t[0])*(p.test(t[0])?i/100:1),parseFloat(t[1])*(p.test(t[1])?o/100:1)]}function o(i,o){return parseInt(t.css(i,o),10)||0}var e,l=Math.max,n=Math.abs,f=/left|center|right/,s=/top|center|bottom/,h=/[\+\-]\d+(\.[\d]+)?%?/,r=/^\w+/,p=/%$/,c=t.fn.position;t.position={scrollbarWidth:function(){if(void 0!==e)return e;var i,o,l=t("
"),n=l.children()[0];return t("body").append(l),i=n.offsetWidth,l.css("overflow","scroll"),i===(o=n.offsetWidth)&&(o=l[0].clientWidth),l.remove(),e=i-o},getScrollInfo:function(i){var o=i.isWindow||i.isDocument?"":i.element.css("overflow-x"),e=i.isWindow||i.isDocument?"":i.element.css("overflow-y"),l="scroll"===o||"auto"===o&&i.width