diff --git a/.snyk b/.snyk new file mode 100644 index 0000000..85a9e67 --- /dev/null +++ b/.snyk @@ -0,0 +1,22 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.7.1 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + 'npm:ms:20170412': + - socket.io > socket.io-adapter > socket.io-parser > debug > ms: + reason: no patch avail + expires: '2017-06-18T14:21:30.266Z' + - standard > eslint-plugin-import > eslint-module-utils > debug > ms: + reason: no patch avail + expires: '2017-06-18T14:21:30.266Z' + - socket.io > socket.io-adapter > debug > ms: + reason: no patch avail + expires: '2017-06-18T14:21:30.267Z' + - socket.io > socket.io-client > debug > ms: + reason: no patch avail + expires: '2017-06-18T14:21:30.267Z' + 'npm:shelljs:20140723': + - standard > eslint > shelljs: + reason: no patch avail + expires: '2017-06-18T14:21:30.267Z' +patch: {} diff --git a/README.md b/README.md index ecb7d25..91bd305 100644 --- a/README.md +++ b/README.md @@ -33,31 +33,37 @@ headerBackground= - optional background color of header to display on page config.json contains several options which may be specified to customize to your needs, vs editing the javascript direclty. This is JSON format so mind your spacing, brackets, etc... `listen.ip` default `127.0.0.1` -* IP address node should listen on for client connections +* IP address node should listen on for client connections. `listen.port` default `2222` -* Port node should listen on for client connections +* Port node should listen on for client connections. `user.name` default `null` -* Specify user name to authenticate with +* Specify user name to authenticate with. `user.password` default `null` -* Specify password to authenticate with +* Specify password to authenticate with. `ssh.host` default `null` -* Specify host to connect to +* Specify host to connect to. `ssh.port` default `22` -* Specify SSH port to connect to +* Specify SSH port to connect to. `ssh.term` default `xterm-color` -* Specify terminal emulation to use +* Specify terminal emulation to use. `header.text` -* Specify header text, defaults to `My Header` but may also be set to `null` +* Specify header text, defaults to `My Header` but may also be set to `null`. `header.background` -* Header background, defaults to `green` +* Header background, defaults to `green`. + +`session.name` +* Name of session ID cookie. it's not a horrible idea to make this something unique. + +`session.secret` +* Secret key for cookie encryption. You should change this in production. `options.challengeButton` * Challenge button. This option, which is still under development, allows the user to resend the password to the server (in cases of step-up authentication for things like `sudo` or a router `enable` command. diff --git a/config.json b/config.json index 6ad5258..59449fa 100644 --- a/config.json +++ b/config.json @@ -16,6 +16,10 @@ "text": "My Header", "background": "green" }, + "session": { + "name": "WebSSH2id", + "secret": "mysecret" + }, "options": { "challengeButton": true } diff --git a/index.js b/index.js index 9a8db98..c9b948b 100644 --- a/index.js +++ b/index.js @@ -1,73 +1,96 @@ /* * WebSSH2 - Web to SSH2 gateway - * Bill Church - https://github.com/billchurch - April 2016 + * Bill Church - https://github.com/billchurch/WebSSH2 - May 2017 * */ -var express = require('express'), - app = express(), - cookieParser = require('cookie-parser'), - server = require('http').Server(app), - io = require('socket.io')(server), - path = require('path'), - basicAuth = require('basic-auth'), - SSH = require('ssh2').Client, - readConfig = require('read-config'), - config = readConfig(path.join(__dirname, 'config.json')), - myError = ' - ', - termCols, - termRows +var express = require('express') +var app = express() +var server = require('http').Server(app) +var io = require('socket.io')(server) +var path = require('path') +var SSH = require('ssh2').Client +var config = require('read-config')(path.join(__dirname, 'config.json')) +var debug = require('debug') +var dataBuffer = '' +var util = require('./util') +var session = require('express-session')({ + secret: config.session.secret, + name: config.session.name, + resave: true, + saveUninitialized: false +}) +var LogPrefix, termCols, termRows, myError -// function logErrors (err, req, res, next) { -// console.error(err.stack) -// next(err) -// } +var expressOptions = { + dotfiles: 'ignore', + etag: false, + extensions: ['htm', 'html'], + index: false, + maxAge: '1s', + redirect: false, + setHeaders: function (res, path, stat) { + res.set('x-timestamp', Date.now()) + } +} server.listen({ host: config.listen.ip, port: config.listen.port -}).on('error', function (err) { +}) + +server.on('error', function (err) { if (err.code === 'EADDRINUSE') { config.listen.port++ - console.log('Address in use, retrying on port ' + config.listen.port) + console.warn('Address in use, retrying on port ' + config.listen.port) setTimeout(function () { server.listen(config.listen.port) }, 250) + } else { + console.log('server.listen ERROR: ' + err.code) } }) -app.use(express.static(path.join(__dirname, 'public'))).use(function (req, res, next) { - var myAuth = basicAuth(req) - if (myAuth === undefined) { - res.statusCode = 401 - res.setHeader('WWW-Authenticate', 'Basic realm="WebSSH"') - res.end('Username and password required for web SSH service.') - } else if (myAuth.name === '') { - res.statusCode = 401 - res.setHeader('WWW-Authenticate', 'Basic realm="WebSSH"') - res.end('Username and password required for web SSH service.') +app.use(session) +app.use(util.basicAuth) + +io.use(function (socket, next) { + if (socket.request.res) { + session(socket.request, socket.request.res, next) } else { - config.user.name = myAuth.name - config.user.password = myAuth.pass next() } -}).use(cookieParser()).get('/ssh/host/:host?', function (req, res) { +}) + +app.disable('x-powered-by') + +app.use(express.static(path.join(__dirname, 'public'), expressOptions)) + +app.get('/ssh/host/:host?', function (req, res, next) { res.sendFile(path.join(path.join(__dirname, 'public', 'client.htm'))) - config.ssh.host = req.params.host - if (typeof req.query.port !== 'undefined' && req.query.port !== null) { - config.ssh.port = req.query.port - } - if (typeof req.query.header !== 'undefined' && req.query.header !== null) { - config.header.text = req.query.header - } - if (typeof req.query.headerBackground !== 'undefined' && req.query.headerBackground !== null) { - config.header.background = req.query.headerBackground - } - console.log('webssh2 Login: user=' + config.user.name + ' from=' + req.ip + ' host=' + config.ssh.host + ' port=' + config.ssh.port + ' sessionID=' + req.headers.sessionid + ' allowreplay=' + req.headers.allowreplay) - console.log('Headers: ' + JSON.stringify(req.headers)) + // capture url variables if defined + config.ssh.host = req.params.host || config.ssh.host + config.ssh.port = req.query.port || config.ssh.port + config.header.text = req.query.header || config.header.text + config.header.background = req.query.headerBackground || config.header.background + console.log('webssh2 Login: user=' + req.session.username + ' from=' + req.ip + ' host=' + config.ssh.host + ' port=' + config.ssh.port + ' sessionID=' + req.sessionID + ' allowreplay=' + req.headers.allowreplay) + LogPrefix = req.session.username + '@' + req.ip + ' ssh://' + config.ssh.host + ':' + config.ssh.port + '/' + req.sessionID + // console.log('Headers: ' + JSON.stringify(req.headers)) config.options.allowreplay = req.headers.allowreplay -}).use('/style', express.static(path.join(__dirname, 'public'))).use('/src', express.static(path.join(__dirname, 'node_modules', 'xterm', 'dist'))).use('/addons', express.static(path.join(__dirname, 'node_modules', 'xterm', 'dist', 'addons'))) +}) + +app.use('/style', express.static(path.join(__dirname, 'public'))) + +app.use('/src', express.static(path.join(__dirname, 'node_modules', 'xterm', 'dist'))) + +app.use('/addons', express.static(path.join(__dirname, 'node_modules', 'xterm', 'dist', 'addons'))) io.on('connection', function (socket) { + // if websocket connection arrives without an express session, kill it + if (!socket.request.session) { + socket.disconnect(true) + return + } + var conn = new SSH() socket.on('geometry', function (cols, rows) { termCols = cols @@ -78,15 +101,17 @@ io.on('connection', function (socket) { // need to convert to cr/lf for proper formatting d = d.replace(/\r?\n/g, '\r\n') socket.emit('data', d.toString('binary')) - }).on('ready', function () { + }) + + conn.on('ready', function () { socket.emit('title', 'ssh://' + config.ssh.host) socket.emit('headerBackground', config.header.background) socket.emit('header', config.header.text) - socket.emit('footer', 'ssh://' + config.user.name + '@' + config.ssh.host + ':' + config.ssh.port) + socket.emit('footer', 'ssh://' + socket.request.session.username + '@' + config.ssh.host + ':' + config.ssh.port) socket.emit('status', 'SSH CONNECTION ESTABLISHED') - socket.emit('statusBackground', 'green') + socket.emit('statusBackground', config.header.background) socket.emit('allowreplay', config.options.allowreplay) - + conn.shell({ term: config.ssh.term, cols: termCols, @@ -94,16 +119,26 @@ io.on('connection', function (socket) { }, function (err, stream) { if (err) { console.log(err.message) - myError = myError + err.message - return socket.emit('status', 'SSH EXEC ERROR: ' + err.message).emit('statusBackground', 'red') + myError = err.message + socket.emit('status', 'SSH EXEC ERROR: ' + err.message) + socket.emit('statusBackground', 'red') + console.log('conn.shell err: ' + err.message) + return socket.close(true) } socket.on('data', function (data) { stream.write(data) + // poc to log commands from client + // if (data === '\r') { + // console.log(LogPrefix + ': ' + dataBuffer) + // dataBuffer = '' + // } else { + // dataBuffer = dataBuffer + data + // } }) socket.on('control', function (controlData) { switch (controlData) { case 'replayCredentials': - stream.write(config.user.password + '\n') + stream.write(socket.request.session.userpassword + '\n') /* falls through */ default: console.log('controlData: ' + controlData) @@ -112,40 +147,61 @@ io.on('connection', function (socket) { stream.on('data', function (d) { socket.emit('data', d.toString('binary')) - }).on('close', function (code, signal) { + }) + + stream.on('close', function (code, signal) { console.log('Stream :: close :: code: ' + code + ', signal: ' + signal) conn.end() socket.disconnect() - }).stderr.on('data', function (data) { + }) + + stream.stderr.on('data', function (data) { console.log('STDERR: ' + data) }) }) - }).on('end', function () { - socket.emit('status', 'SSH CONNECTION CLOSED BY HOST' + myError) - socket.emit('statusBackground', 'red') - socket.disconnect() - }).on('close', function () { - socket.emit('status', 'SSH CONNECTION CLOSE' + myError) - socket.emit('statusBackground', 'red') - socket.disconnect() - }).on('error', function (err) { - myError = myError + err - socket.emit('status', 'SSH CONNECTION ERROR' + myError) - socket.emit('statusBackground', 'red') - console.log('on.error' + myError) - }).on('keyboard-interactive', function (name, instructions, instructionsLang, prompts, finish) { - console.log('Connection :: keyboard-interactive') - finish([config.user.password]) - }).connect({ - host: config.ssh.host, - port: config.ssh.port, - username: config.user.name, - password: config.user.password, - tryKeyboard: true, - // some cisco routers need the these cipher strings - algorithms: { - 'cipher': ['aes128-cbc', '3des-cbc', 'aes256-cbc'], - 'hmac': ['hmac-sha1', 'hmac-sha1-96', 'hmac-md5-96'] - } }) + + conn.on('end', function () { + socket.emit('status', 'SSH CONNECTION CLOSED BY HOST ' + myError) + socket.emit('statusBackground', 'red') + socket.disconnect() + }) + + conn.on('close', function () { + socket.emit('status', 'SSH CONNECTION CLOSE ' + myError) + socket.emit('statusBackground', 'red') + socket.disconnect() + }) + + conn.on('error', function (err) { + myError = err + socket.emit('status', 'SSH CONNECTION ERROR ' + myError) + socket.emit('statusBackground', 'red') + console.error('conn.on(\'error\'): ' + myError) + }) + + conn.on('keyboard-interactive', function (name, instructions, instructionsLang, prompts, finish) { + console.log('Connection :: keyboard-interactive') + finish([socket.request.session.userpassword]) + }) + if (socket.request.session.username && socket.request.session.userpassword) { + conn.connect({ + host: config.ssh.host, + port: config.ssh.port, + username: socket.request.session.username, + password: socket.request.session.userpassword, + tryKeyboard: true, + // some cisco routers need the these cipher strings + algorithms: { + 'cipher': ['aes128-cbc', '3des-cbc', 'aes256-cbc', 'aes128-ctr', 'aes192-ctr', 'aes256-ctr'], + 'hmac': ['hmac-sha1', 'hmac-sha1-96', 'hmac-md5-96'] + }, + debug: debug('WebSSH2:debug') + }) + } else { + console.warn('Attempt to connect without session.username/password defined, potentially previously abandoned client session. disconnecting websocket client.\r\nHandshake information: \r\n ' + JSON.stringify(socket.handshake)) + socket.emit('statusBackground', 'red') + socket.emit('status', 'WEBSOCKET ERROR - Reload and try again') + socket.disconnect(true) + } }) diff --git a/package.json b/package.json index 6a577ac..bc5f461 100644 --- a/package.json +++ b/package.json @@ -28,15 +28,20 @@ }, "dependencies": { "basic-auth": "^1.1.0", - "cookie-parser": "^1.4.3", - "express": "^4.14.1", + "colors": "^1.1.2", + "debug": "^2.6.7", + "express": "^4.15.3", + "express-session": "^1.15.3", "read-config": "^1.6.0", "socket.io": "^1.6.0", "ssh2": "^0.5.4", - "strip-ansi": "^3.0.1", - "xterm": "^2.4.0" + "xterm": "^2.6.0" }, "scripts": { - "start": "node index" + "start": "node index", + "test": "snyk test" + }, + "devDependencies": { + "snyk": "^1.30.1" } } diff --git a/public/client.js b/public/client.js index 8f24d3f..d6d7afe 100644 --- a/public/client.js +++ b/public/client.js @@ -62,7 +62,9 @@ var terminalContainer = document.getElementById('terminal-container'), }), socket, termid -term.open(terminalContainer) +term.open(terminalContainer, { + focus: true +}) term.fit() if (document.location.pathname) { @@ -106,12 +108,12 @@ socket.on('connect', function () { if (sessionLogEnable) { sessionLog = sessionLog + data } - }).on('disconnect', function (err) { - document.getElementById('status').style.backgroundColor = 'red' - document.getElementById('status').innerHTML = 'WEBSOCKET SERVER DISCONNECTED' + err - socket.io.reconnection(false) - }).on('error', function (err) { - document.getElementById('status').style.backgroundColor = 'red' - document.getElementById('status').innerHTML = 'ERROR ' + err - }) + })// .on('disconnect', function (err) { + // document.getElementById('status').style.backgroundColor = 'red' + // document.getElementById('status').innerHTML = 'WEBSOCKET SERVER DISCONNECTED' + err + // socket.io.reconnection(false) + // })//.on('error', function (err) { + // document.getElementById('status').style.backgroundColor = 'red' + // document.getElementById('status').innerHTML = 'ERROR ' + err + // }) }) diff --git a/util/index.js b/util/index.js new file mode 100644 index 0000000..475e5ab --- /dev/null +++ b/util/index.js @@ -0,0 +1,27 @@ +var colors = require('colors'); +var Auth = require('basic-auth') + +console.warn = makeColorConsole(console.warn, 'yellow') +console.error = makeColorConsole(console.error, 'red') + +function makeColorConsole(fct, color){ + return function(){ + for (var i in arguments) + if (arguments[i] instanceof Object) + arguments[i] = sys.inspect(arguments[i]); + fct(Array.prototype.join.call(arguments," ")[color]); + }; +} + +exports.basicAuth = function (req, res, next) { + var myAuth = Auth(req) + if (myAuth) { + req.session.username = myAuth.name + req.session.userpassword = myAuth.pass + next() + } else { + res.statusCode = 401 + res.setHeader('WWW-Authenticate', 'Basic realm="WebSSH"') + res.end('Username and password required for web SSH service.') + } +} \ No newline at end of file