diff --git a/ChangeLog.md b/ChangeLog.md index d14bf08..8557ce8 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -5,12 +5,17 @@ - Snyk, Bithound, Travis CI - Cross platform improvements (path mappings) - Session fixup between Express and Socket.io +- Session secret settings in config.json - env variable `DEBUG=ssh2` will put the `ssh2` module into debug mode -- env variable `debug=WebSSH2` will output additional debug messages for functions +- env variable `DEBUG=WebSSH2` will output additional debug messages for functions and events in the application (not including the ssh2 module debug) ### Changed - erorr handling in public/client.js +- moved socket.io operations to their own file /socket/index.js, more changes like this to come (./socket/index.js) +- all session based variables are now under the req.session.ssh property or socket.request.ssh (./index.js) +- moved SSH algorithms to config.json and defined as a session variable (..session.ssh.algorithms) +-- prep for future feature to define algorithims in header or some other method to enable seperate ciphers per host ### Fixed - Multiple errors may ovewrite status bar which would cause confusion as to what originally caused the error. Example, ssh server disconnects which prompts a cascade of events (conn.on('end'), socket.on('disconnect'), conn.on('close')) and the original reason (conn.on('end')) would be lost and the user would erroneously receive a WEBSOCKET error as the last event to fire would be the websocket connection closing from the app. diff --git a/config.json b/config.json index 59449fa..1083601 100644 --- a/config.json +++ b/config.json @@ -10,17 +10,32 @@ "ssh": { "host": null, "port": 22, - "term": "xterm-color", + "term": "xterm-color" }, "header": { "text": "My Header", "background": "green" }, "session": { - "name": "WebSSH2id", + "name": "WebSSH2", "secret": "mysecret" }, "options": { "challengeButton": true + }, + "algorithms": { + "cipher": [ + "aes128-cbc", + "3des-cbc", + "aes256-cbc", + "aes128-ctr", + "aes192-ctr", + "aes256-ctr" + ], + "hmac": [ + "hmac-sha1", + "hmac-sha1-96", + "hmac-md5-96" + ] } -} +} \ No newline at end of file diff --git a/index.js b/index.js index 84ac552..6781c9a 100644 --- a/index.js +++ b/index.js @@ -8,40 +8,17 @@ 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 debugWebSSH2 = debug('WebSSH2') -var util = require('./util') -var SocketUtil = require('./socket') +// var debug = require('debug') +// var debugWebSSH2 = debug('WebSSH2') +var myutil = require('./util') +var socket = require('./socket/index.js') var session = require('express-session')({ secret: config.session.secret, name: config.session.name, resave: true, - saveUninitialized: false -}) -var colors = require('colors/safe') -var termCols, termRows -// var LogPrefix -// var dataBuffer = '' - -// server - -server.listen({ - host: config.listen.ip, - port: config.listen.port -}) - -server.on('error', function (err) { - if (err.code === 'EADDRINUSE') { - 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) - } + saveUninitialized: false, + unset: 'destroy' }) // express @@ -58,30 +35,33 @@ var expressOptions = { } app.use(session) -app.use(util.basicAuth) +app.use(myutil.basicAuth) 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'))) - // 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) - debugWebSSH2('Headers: ' + colors.yellow(JSON.stringify(req.headers))) - config.options.allowreplay = req.headers.allowreplay + // capture and assign variables + req.session.ssh = { + host: req.params.host || config.ssh.host, + port: req.query.port || config.ssh.port, + header: { + name: req.query.header || config.header.text, + background: req.query.headerBackground || config.header.background + }, + algorithms: config.algorithms, + term: config.ssh.term, + allowreplay: req.headers.allowreplay || false + } }) +// static files +app.use(express.static(path.join(__dirname, 'public'), expressOptions)) 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'))) +// express error handling app.use(function (req, res, next) { res.status(404).send("Sorry can't find that!") }) @@ -92,124 +72,28 @@ app.use(function (err, req, res, next) { }) // socket.io - +// expose express session with socket.request.session io.use(function (socket, next) { - if (socket.request.res) { - session(socket.request, socket.request.res, next) - } else { - next() - } + (socket.request.res) ? session(socket.request, socket.request.res, next) : next() }) -io.on('connection', function (socket) { - // if websocket connection arrives without an express session, kill it - if (!socket.request.session) { - socket.disconnect(true) - return - } - var socketutil = new SocketUtil(socket, io) - var conn = new SSH() - socket.on('geometry', function (cols, rows) { - termCols = cols - termRows = rows - }) +// bring up socket +io.on('connection', socket) - conn.on('banner', function (d) { - // need to convert to cr/lf for proper formatting - d = d.replace(/\r?\n/g, '\r\n') - socket.emit('data', d.toString('binary')) - }) +// server +server.listen({ + host: config.listen.ip, + port: config.listen.port +}) - 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://' + socket.request.session.username + '@' + config.ssh.host + ':' + config.ssh.port) - socket.emit('status', 'SSH CONNECTION ESTABLISHED') - socket.emit('statusBackground', config.header.background) - socket.emit('allowreplay', config.options.allowreplay) - - conn.shell({ - term: config.ssh.term, - cols: termCols, - rows: termRows - }, function (err, stream) { - if (err) { - socketutil.SSHerror('EXEC ERROR' + err) - conn.end() - return - } - 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(socket.request.session.userpassword + '\n') - /* falls through */ - default: - console.log('controlData: ' + controlData) - } - }) - - socket.on('disconnecting', function (reason) { debugWebSSH2('SOCKET DISCONNECTING: ' + reason) }) - - socket.on('disconnect', function (reason) { - debugWebSSH2('SOCKET DISCONNECT: ' + reason) - err = { message: reason } - socketutil.SSHerror('CLIENT SOCKET DISCONNECT', err) - conn.end() - }) - - socket.on('error', function (error) { debugWebSSH2('SOCKET ERROR: ' + JSON.stringify(error)) }) - - stream.on('data', function (d) { socket.emit('data', d.toString('binary')) }) - - stream.on('close', function (code, signal) { - err = { message: ((code || signal) ? (((code) ? 'CODE: ' + code : '') + ((code && signal) ? ' ' : '') + ((signal) ? 'SIGNAL: ' + signal : '')) : undefined) } - socketutil.SSHerror('STREAM CLOSE', err) - conn.end() - }) - - stream.stderr.on('data', function (data) { - console.log('STDERR: ' + data) - }) - }) - }) - - conn.on('end', function (err) { socketutil.SSHerror('CONN END BY HOST', err) }) - conn.on('close', function (err) { socketutil.SSHerror('CONN CLOSE', err) }) - conn.on('error', function (err) { socketutil.SSHerror('CONN ERROR', err) }) - - conn.on('keyboard-interactive', function (name, instructions, instructionsLang, prompts, finish) { - debugWebSSH2('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('ssh2') - }) +server.on('error', function (err) { + if (err.code === 'EADDRINUSE') { + config.listen.port++ + console.warn('Address in use, retrying on port ' + config.listen.port) + setTimeout(function () { + server.listen(config.listen.port) + }, 250) } 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) + console.log('server.listen ERROR: ' + err.code) } }) diff --git a/package.json b/package.json index 4c6e00e..035f8e2 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,12 @@ }, "scripts": { "start": "node index", - "test": "snyk test" + "test": "snyk test", + "watch": "nodemon index.js" }, "devDependencies": { "bithound": "^1.7.0", + "nodemon": "^1.11.0", "snyk": "^1.30.1" } } diff --git a/socket/index.js b/socket/index.js index 436409c..fbb8576 100644 --- a/socket/index.js +++ b/socket/index.js @@ -1,12 +1,143 @@ -var debug = require('debug')('WebSSH2') -var myError +// private +var debug = require('debug') +var debugWebSSH2 = require('debug')('WebSSH2') +var SSH = require('ssh2').Client +var termCols, termRows -module.exports = function (socket, io) { - this.SSHerror = function (myFunc, err) { - myError = (myError) || ((err) ? err.message : undefined) - var thisError = (myError) ? ': ' + myError : '' - debug('SSH ' + myFunc + thisError) - socket.emit('ssherror', 'SSH ' + myFunc + thisError) +// public +module.exports = function (socket) { + function SSHerror (myFunc, err) { + socket.request.session.error = (socket.request.session.error) || ((err) ? err.message : undefined) + var theError = (socket.request.session.error) ? ': ' + socket.request.session.error : '' + // log unsuccessful login attempt + if (err && (err.level === 'client-authentication')) { + console.log('webssh2 ' + 'error: Authentication failure'.red.bold + + ' user=' + socket.request.session.username.yellow.bold.underline + + ' from=' + socket.handshake.address.yellow.bold.underline) + } + switch (myFunc) { + case 'STREAM CLOSE': + debugWebSSH2('SSH ' + myFunc + theError.red) + socket.emit('ssherror', 'SSH ' + myFunc + theError) + socket.disconnect(true) + break + default: + debugWebSSH2('SSHerror: default'.red) + debugWebSSH2('SSH ' + myFunc + theError) + socket.emit('ssherror', 'SSH ' + myFunc + theError) + socket.disconnect(true) + } + } + + // if websocket connection arrives without an express session, kill it + if (!socket.request.session) { + socket.emit('401 UNAUTHORIZED') + debugWebSSH2('SOCKET: No Express Session / REJECTED') + socket.disconnect(true) + return + } + var conn = new SSH() + socket.on('geometry', function (cols, rows) { + termCols = cols + termRows = rows + }) + console.log('webssh2 ' + 'IO ON:'.cyan.bold + ' user=' + socket.request.session.username + ' from=' + socket.handshake.address + ' host=' + socket.request.session.ssh.host + ' port=' + socket.request.session.ssh.port + ' sessionID=' + socket.request.sessionID + '/' + socket.id + ' allowreplay=' + socket.request.session.ssh.allowreplay + ' term=' + socket.request.session.ssh.term) + conn.on('banner', function (d) { + // need to convert to cr/lf for proper formatting + d = d.replace(/\r?\n/g, '\r\n') + socket.emit('data', d.toString('binary')) + }) + + conn.on('ready', function () { + console.log('webssh2 Login: user=' + socket.request.session.username + ' from=' + socket.handshake.address + ' host=' + socket.request.session.ssh.host + ' port=' + socket.request.session.ssh.port + ' sessionID=' + socket.request.sessionID + '/' + socket.id + ' allowreplay=' + socket.request.session.ssh.allowreplay + ' term=' + socket.request.session.ssh.term) + socket.emit('title', 'ssh://' + socket.request.session.ssh.host) + socket.emit('headerBackground', socket.request.session.ssh.header.background) + socket.emit('header', socket.request.session.ssh.header.name) + socket.emit('footer', 'ssh://' + socket.request.session.username + '@' + socket.request.session.ssh.host + ':' + socket.request.session.ssh.port) + socket.emit('status', 'SSH CONNECTION ESTABLISHED') + socket.emit('statusBackground', socket.request.session.ssh.header.background) + socket.emit('allowreplay', socket.request.session.ssh.allowreplay) + + conn.shell({ + term: socket.request.session.ssh.term, + cols: termCols, + rows: termRows + }, function (err, stream) { + if (err) { + SSHerror('EXEC ERROR' + err) + conn.end() + return + } + // poc to log commands from client + // var dataBuffer + socket.on('data', function (data) { + stream.write(data) + // poc to log commands from client + // if (data === '\r') { + // console.log(socket.request.session.id + '/' + socket.id + ' command: ' + socket.request.session.ssh.host + ': ' + dataBuffer) + // dataBuffer = undefined + // } else { + // dataBuffer = (dataBuffer) ? dataBuffer + data : data + // } + }) + socket.on('control', function (controlData) { + switch (controlData) { + case 'replayCredentials': + stream.write(socket.request.session.userpassword + '\n') + /* falls through */ + default: + console.log('controlData: ' + controlData) + } + }) + + socket.on('disconnecting', function (reason) { debugWebSSH2('SOCKET DISCONNECTING: ' + reason) }) + + socket.on('disconnect', function (reason) { + debugWebSSH2('SOCKET DISCONNECT: ' + reason) + err = { message: reason } + SSHerror('CLIENT SOCKET DISCONNECT', err) + conn.end() + }) + + socket.on('error', function (error) { debugWebSSH2('SOCKET ERROR: ' + JSON.stringify(error)) }) + + stream.on('data', function (d) { socket.emit('data', d.toString('binary')) }) + + stream.on('close', function (code, signal) { + err = { message: ((code || signal) ? (((code) ? 'CODE: ' + code : '') + ((code && signal) ? ' ' : '') + ((signal) ? 'SIGNAL: ' + signal : '')) : undefined) } + SSHerror('STREAM CLOSE', err) + conn.end() + }) + + stream.stderr.on('data', function (data) { + console.log('STDERR: ' + data) + }) + }) + }) + + conn.on('end', function (err) { SSHerror('CONN END BY HOST', err) }) + conn.on('close', function (err) { SSHerror('CONN CLOSE', err) }) + conn.on('error', function (err) { SSHerror('CONN ERROR', err) }) + + conn.on('keyboard-interactive', function (name, instructions, instructionsLang, prompts, finish) { + debugWebSSH2('conn.on(\'keyboard-interactive\')') + finish([socket.request.session.userpassword]) + }) + if (socket.request.session.username && socket.request.session.userpassword) { + conn.connect({ + host: socket.request.session.ssh.host, + port: socket.request.session.ssh.port, + username: socket.request.session.username, + password: socket.request.session.userpassword, + tryKeyboard: true, + // some cisco routers need the these cipher strings + algorithms: socket.request.session.ssh.algorithms, + debug: debug('ssh2') + }) + } 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/util/index.js b/util/index.js index 1410f3a..ebbfa67 100644 --- a/util/index.js +++ b/util/index.js @@ -1,10 +1,13 @@ +// private +require('colors') // allow for color property extensions in log messages var debug = require('debug')('WebSSH2') -var colors = require('colors') var Auth = require('basic-auth') +var util = require('util') console.warn = makeColorConsole(console.warn, 'yellow') console.error = makeColorConsole(console.error, 'red') +// public function makeColorConsole (fct, color) { return function () { for (var i in arguments) { @@ -19,7 +22,7 @@ exports.basicAuth = function (req, res, next) { if (myAuth) { req.session.username = myAuth.name req.session.userpassword = myAuth.pass - debug('myAuth.name: ' + myAuth.name + ' and password ' + ((myAuth.pass) ? 'exists' : 'is blank'.underline.red)) + debug('myAuth.name: ' + myAuth.name.yellow.bold.underline + ' and password ' + ((myAuth.pass) ? 'exists'.yellow.bold.underline : 'is blank'.underline.red.bold)) next() } else { res.statusCode = 401 @@ -28,3 +31,10 @@ exports.basicAuth = function (req, res, next) { res.end('Username and password required for web SSH service.') } } + +// expects headers to be a JSON object, will replace authroization header with 'Sanatized//Exists' +// we don't want to log basic auth header since it contains a password... +exports.SanatizeHeaders = function (headers) { + if (headers.authorization) { headers.authorization = 'Sanitized//Exists' } + return (headers) +}