/* eslint-disable complexity */
'use strict'
/* jshint esversion: 6, asi: true, node: true */
// socket.js
// private
var config = require('./config')
var validator = require('validator')
var debug = require('debug')
var myutil = require('./util')
var debugWebSSH2 = require('debug')('WebSSH2')
var SSH = require('ssh2').Client
var CIDRMatcher = require('cidr-matcher')
// var fs = require('fs')
// var hostkeys = JSON.parse(fs.readFileSync('./hostkeyhashes.json', 'utf8'))
var termCols, termRows
var menuData = ' Start Log' +
' Download Log'
// return a subset of keys from an object if they exist
function pick (obj, keys) {
return keys.reduce((acc, key) => {
if (obj[key]) {
acc[key] = obj[key]
}
return acc
}, {})
}
const baseSocketConfig = {
host: config.ssh.host,
port: config.ssh.port,
localAddress: config.ssh.localAddress,
localPort: config.ssh.localPort,
term: config.ssh.term,
readyTimeout: config.ssh.readyTimeout,
algorithms: config.algorithms,
keepaliveInterval: config.ssh.keepaliveInterval,
keepaliveCountMax: config.ssh.keepaliveCountMax,
allowedSubnets: config.ssh.allowedSubnets || [],
header: {
name: config.header.text,
background: config.header.background
},
terminal: {
cursorBlink: config.terminal.cursorBlink,
scrollBack: config.terminal.scrollback,
tabStopWidth: config.terminal.tabStopWidth,
bellStyle: config.terminal.bellStyle
},
serverlog: {
client: config.serverlog.client || false,
server: config.serverlog.server || false
}
}
function getValidatedRequestConfig (queryParams) {
const processedParams = {}
const validators = {
host: (host) => validator.isIP(host + '') || validator.isFQDN(host) || /^(([a-z]|[A-Z]|[0-9]|[!^(){}\-_~])+)?\w$/.test(host),
port: (port) => validator.isInt(port + '', { min: 1, max: 65535 }),
sshterm: (sshterm) => /^(([a-z]|[A-Z]|[0-9]|[!^(){}\-_~])+)?\w$/.test(sshterm),
cursorBlink: (cursorBlink) => validator.isBoolean(cursorBlink + ''),
scrollback: (scrollback) => validator.isInt(scrollback + '', { min: 1, max: 200000 }),
tabStopWidth: (tabStopWidth) => validator.isInt(tabStopWidth + '', { min: 1, max: 100 }),
bellStyle: (bellStyle) => (['sound', 'none'].indexOf(bellStyle) > -1),
readyTimeout: (readyTimeout) => validator.isInt(readyTimeout + '', { min: 1, max: 300000 }),
header: () => true,
headerBackground: () => true
}
const transformations = {
cursorBlink: (cursorBlink) => myutil.parseBool(cursorBlink)
}
const rename = {
sshterm: 'term'
}
// validate & transform and rename query parameters
for (const key in queryParams) {
const value = queryParams[key]
const validator = validators[key] || (() => false)
const transformation = transformations[key] || ((i) => i)
const newName = rename[key] || key
if (value !== undefined && validator(value)) {
processedParams[newName] = transformation(value)
}
}
// todo: address all this!!
// const allowreplay = config.options.challengeButton || (validator.isBoolean(req.headers.allowreplay + '') ? myutil.parseBool(req.headers.allowreplay) : false)
// const allowreauth = config.options.allowreauth || false
// const mrhsession = ((validator.isAlphanumeric(req.headers.mrhsession + '') && req.headers.mrhsession) ? req.headers.mrhsession : 'none')
// if (req.session.ssh.header.name) validator.escape(req.session.ssh.header.name)
// if (req.session.ssh.header.background) validator.escape(req.session.ssh.header.background)
// todo: do this when creating base config?
// if (socketConfig.header.name) {
// validator.escape(socketConfig.header.name)
// }
// if (socketConfig.header.background) {
// validator.escape(socketConfig.header.background)
// }
// create config object from query parameters
const config = pick(processedParams, ['host', 'port', 'readyTimeout', 'term'])
config.terminal = pick(processedParams, ['cursorBlink', 'scrollback', 'tabStopWidth', 'bellStyle'])
config.header = pick(processedParams, ['header', 'headerBackground'])
return config
}
function getCredentials (session) {
if (session.username && session.userpassword) {
return {
username: session.username,
userpassword: session.userpassword
}
} else {
return myutil.defaultCredentials
}
}
/**
* Error handling for various events. Outputs error to client, logs to
* server, destroys session and disconnects socket.
* @param {string} callerName Function calling this function
* @param {object} err Error object or error message
* @param {object} context Additional information about the state when the error occurred
* @param {object} context.socket The socket.io socket object at the time of failure
* @param {object} context.socketConfig The config object based on the base config and the request query parameters
* @param {object} context.credentials The credentials used during the connection that failed
*/
// eslint-disable-next-line complexity
function SSHError (callerName, err, { socket, credentials, socketConfig }) {
var theError
const session = socket.request.session
if (session) {
// we just want the first error of the session to pass to the client
session.error = session.error || ((err) ? err.message : undefined)
theError = session.error ? ': ' + session.error : ''
// log unsuccessful login attempt
if (err && (err.level === 'client-authentication')) {
console.log('WebSSH2 ' + 'error: Authentication failure'.red.bold +
' user=' + credentials.username.yellow.bold.underline +
' from=' + socket.handshake.address.yellow.bold.underline)
socket.emit('allowreauth', socketConfig.allowreauth)
socket.emit('reauth')
} else {
console.log('WebSSH2 Logout: user=' + credentials.username +
' from=' + socket.handshake.address +
' host=' + socketConfig.host +
' port=' + socketConfig.port +
' sessionID=' + socket.request.sessionID + '/' + socket.id +
' allowreplay=' + socketConfig.allowreplay +
' term=' + socketConfig.term
)
if (err) {
theError = err ? ': ' + err.message : ''
console.log('WebSSH2 error' + theError)
}
}
socket.emit('ssherror', 'SSH ' + callerName + theError)
session.destroy()
} else {
theError = (err) ? ': ' + err.message : ''
}
socket.disconnect(true)
debugWebSSH2('SSHError ' + callerName + theError)
}
// public
module.exports = function socket (socket) {
// create new config by merging config object from disk with config object from the request
const socketConfig = Object.assign({}, baseSocketConfig, getValidatedRequestConfig(socket.handshake.query))
const credentials = getCredentials(socket.request.session)
const hasCredentials = credentials.username && (credentials.userpassword || credentials.privatekey)
const errorContext = { socket, credentials, socketConfig };
if (!(hasCredentials && socketConfig)) {
debugWebSSH2('Attempt to connect without session.username/password or session varialbles defined, ' +
'potentially previously abandoned client session. disconnecting websocket client.\r\n' +
'Handshake information: \r\n ' + JSON.stringify(socket.handshake))
socket.emit('ssherror', 'WEBSOCKET ERROR - Refresh the browser and try again')
socket.request.session.destroy()
socket.disconnect(true)
return
}
// If configured, check that requsted host is in a permitted subnet
if (socketConfig.allowedSubnets.length > 0) {
const matcher = new CIDRMatcher(socketConfig.allowedSubnets)
if (!matcher.contains(socketConfig.host)) {
console.log('WebSSH2 ' + 'error: Requested host outside configured subnets / REJECTED'.red.bold +
' user=' + credentials.username.yellow.bold.underline +
' from=' + socket.handshake.address.yellow.bold.underline)
socket.emit('ssherror', '401 UNAUTHORIZED')
socket.disconnect(true)
return
}
}
const conn = new SSH()
socket.on('geometry', function socketOnGeometry (cols, rows) {
termCols = cols
termRows = rows
})
conn.on('banner', function connOnBanner (data) {
// need to convert to cr/lf for proper formatting
data = data.replace(/\r?\n/g, '\r\n')
socket.emit('data', data.toString('utf-8'))
})
conn.on('ready', function connOnReady () {
console.log('WebSSH2 Login: user=' + credentials.username +
' from=' + socket.handshake.address +
' host=' + socketConfig.host +
' port=' + socketConfig.port +
' sessionID=' + socket.request.sessionID + '/' + socket.id +
' mrhsession=' + socketConfig.mrhsession +
' allowreplay=' + socketConfig.allowreplay +
' term=' + socketConfig.term
)
socket.emit('menu', menuData)
socket.emit('allowreauth', socketConfig.allowreauth)
socket.emit('setTerminalOpts', socketConfig.terminal)
socket.emit('title', 'ssh://' + socketConfig.host)
if (socketConfig.header.background) socket.emit('headerBackground', socketConfig.header.background)
if (socketConfig.header.name) socket.emit('header', socketConfig.header.name)
socket.emit('footer', 'ssh://' + credentials.username + '@' + socketConfig.host + ':' + socketConfig.port)
socket.emit('status', 'SSH CONNECTION ESTABLISHED')
socket.emit('statusBackground', 'green')
socket.emit('allowreplay', socketConfig.allowreplay)
conn.shell({
term: socketConfig.term,
cols: termCols,
rows: termRows
}, function connShell (err, stream) {
if (err) {
SSHError('EXEC ERROR', err, errorContext)
conn.end()
return
}
// poc to log commands from client
if (socketConfig.serverlog.client) var dataBuffer
socket.on('data', function socketOnData (data) {
stream.write(data)
// poc to log commands from client
if (socketConfig.serverlog.client) {
if (data === '\r') {
console.log('serverlog.client: ' + socket.request.session.id + '/' + socket.id + ' host: ' + socketConfig.host + ' command: ' + dataBuffer)
dataBuffer = undefined
} else {
dataBuffer = (dataBuffer) ? dataBuffer + data : data
}
}
})
socket.on('control', function socketOnControl (controlData) {
switch (controlData) {
case 'replayCredentials':
if (socketConfig.allowreplay) {
stream.write(credentials.userpassword + '\n')
}
/* falls through */
default:
console.log('controlData: ' + controlData)
}
})
socket.on('resize', function socketOnResize (data) {
stream.setWindow(data.rows, data.cols)
})
socket.on('disconnecting', function socketOnDisconnecting (reason) { debugWebSSH2('SOCKET DISCONNECTING: ' + reason) })
socket.on('disconnect', function socketOnDisconnect (reason) {
debugWebSSH2('SOCKET DISCONNECT: ' + reason)
err = { message: reason }
SSHError('CLIENT SOCKET DISCONNECT', err, errorContext)
conn.end()
// socket.request.session.destroy()
})
socket.on('error', function socketOnError (err) {
SSHError('SOCKET ERROR', err, errorContext)
conn.end()
})
stream.on('data', function streamOnData (data) { socket.emit('data', data.toString('utf-8')) })
stream.on('close', function streamOnClose (code, signal) {
err = { message: ((code || signal) ? (((code) ? 'CODE: ' + code : '') + ((code && signal) ? ' ' : '') + ((signal) ? 'SIGNAL: ' + signal : '')) : undefined) }
SSHError('STREAM CLOSE', err, errorContext)
conn.end()
})
stream.stderr.on('data', function streamStderrOnData (data) {
console.log('STDERR: ' + data)
})
})
})
conn.on('end', function connOnEnd (err) { SSHError('CONN END BY HOST', err, errorContext) })
conn.on('close', function connOnClose (err) { SSHError('CONN CLOSE', err, errorContext) })
conn.on('error', function connOnError (err) { SSHError('CONN ERROR', err, errorContext) })
conn.on('keyboard-interactive', function connOnKeyboardInteractive (name, instructions, instructionsLang, prompts, finish) {
debugWebSSH2('conn.on(\'keyboard-interactive\')')
finish([credentials.userpassword])
})
// console.log('hostkeys: ' + hostkeys[0].[0])
conn.connect({
host: socketConfig.host,
port: socketConfig.port,
localAddress: socketConfig.localAddress,
localPort: socketConfig.localPort,
username: credentials.username,
password: credentials.userpassword,
privateKey: credentials.privatekey,
tryKeyboard: true,
algorithms: socketConfig.algorithms,
readyTimeout: socketConfig.readyTimeout,
keepaliveInterval: socketConfig.keepaliveInterval,
keepaliveCountMax: socketConfig.keepaliveCountMax,
debug: debug('ssh2')
})
}