1293 lines
39 KiB
JavaScript
1293 lines
39 KiB
JavaScript
// TODO:
|
|
// * convert listenerCount() usage to emit() return value checking?
|
|
// * emit error when connection severed early (e.g. before handshake)
|
|
// * add '.connected' or similar property to connection objects to allow
|
|
// immediate connection status checking
|
|
'use strict'
|
|
|
|
require('./buffer-polyfill.js')
|
|
|
|
const { Server: netServer } = require('bare-node-net')
|
|
const EventEmitter = require('bare-node-events')
|
|
const { listenerCount } = EventEmitter
|
|
|
|
const {
|
|
CHANNEL_OPEN_FAILURE,
|
|
DEFAULT_CIPHER,
|
|
DEFAULT_COMPRESSION,
|
|
DEFAULT_KEX,
|
|
DEFAULT_MAC,
|
|
DEFAULT_SERVER_HOST_KEY,
|
|
DISCONNECT_REASON,
|
|
DISCONNECT_REASON_BY_VALUE,
|
|
SUPPORTED_CIPHER,
|
|
SUPPORTED_COMPRESSION,
|
|
SUPPORTED_KEX,
|
|
SUPPORTED_MAC,
|
|
SUPPORTED_SERVER_HOST_KEY
|
|
} = require('./protocol/constants.js')
|
|
const { init: cryptoInit } = require('./protocol/crypto.js')
|
|
const { KexInit } = require('./protocol/kex.js')
|
|
const { parseKey } = require('./protocol/keyParser.js')
|
|
const Protocol = require('./protocol/Protocol.js')
|
|
const { SFTP } = require('./protocol/SFTP.js')
|
|
const { writeUInt32BE } = require('./protocol/utils.js')
|
|
|
|
const { Channel, MAX_WINDOW, PACKET_SIZE, windowAdjust, WINDOW_THRESHOLD } = require('./Channel.js')
|
|
|
|
const {
|
|
ChannelManager,
|
|
generateAlgorithmList,
|
|
isWritable,
|
|
onChannelOpenFailure,
|
|
onCHANNEL_CLOSE
|
|
} = require('./utils.js')
|
|
|
|
const MAX_PENDING_AUTHS = 10
|
|
|
|
class AuthContext extends EventEmitter {
|
|
constructor(protocol, username, service, method, cb) {
|
|
super()
|
|
|
|
this.username = this.user = username
|
|
this.service = service
|
|
this.method = method
|
|
this._initialResponse = false
|
|
this._finalResponse = false
|
|
this._multistep = false
|
|
this._cbfinal = (allowed, methodsLeft, isPartial) => {
|
|
if (!this._finalResponse) {
|
|
this._finalResponse = true
|
|
cb(this, allowed, methodsLeft, isPartial)
|
|
}
|
|
}
|
|
this._protocol = protocol
|
|
}
|
|
|
|
accept() {
|
|
this._cleanup && this._cleanup()
|
|
this._initialResponse = true
|
|
this._cbfinal(true)
|
|
}
|
|
reject(methodsLeft, isPartial) {
|
|
this._cleanup && this._cleanup()
|
|
this._initialResponse = true
|
|
this._cbfinal(false, methodsLeft, isPartial)
|
|
}
|
|
}
|
|
|
|
class KeyboardAuthContext extends AuthContext {
|
|
constructor(protocol, username, service, method, submethods, cb) {
|
|
super(protocol, username, service, method, cb)
|
|
|
|
this._multistep = true
|
|
|
|
this._cb = undefined
|
|
this._onInfoResponse = (responses) => {
|
|
const callback = this._cb
|
|
if (callback) {
|
|
this._cb = undefined
|
|
callback(responses)
|
|
}
|
|
}
|
|
this.submethods = submethods
|
|
this.on('abort', () => {
|
|
this._cb && this._cb(new Error('Authentication request aborted'))
|
|
})
|
|
}
|
|
|
|
prompt(prompts, title, instructions, cb) {
|
|
if (!Array.isArray(prompts)) prompts = [prompts]
|
|
|
|
if (typeof title === 'function') {
|
|
cb = title
|
|
title = instructions = undefined
|
|
} else if (typeof instructions === 'function') {
|
|
cb = instructions
|
|
instructions = undefined
|
|
} else if (typeof cb !== 'function') {
|
|
cb = undefined
|
|
}
|
|
|
|
for (let i = 0; i < prompts.length; ++i) {
|
|
if (typeof prompts[i] === 'string') {
|
|
prompts[i] = {
|
|
prompt: prompts[i],
|
|
echo: true
|
|
}
|
|
}
|
|
}
|
|
|
|
this._cb = cb
|
|
this._initialResponse = true
|
|
|
|
this._protocol.authInfoReq(title, instructions, prompts)
|
|
}
|
|
}
|
|
|
|
class PKAuthContext extends AuthContext {
|
|
constructor(protocol, username, service, method, pkInfo, cb) {
|
|
super(protocol, username, service, method, cb)
|
|
|
|
this.key = { algo: pkInfo.keyAlgo, data: pkInfo.key }
|
|
this.hashAlgo = pkInfo.hashAlgo
|
|
this.signature = pkInfo.signature
|
|
this.blob = pkInfo.blob
|
|
}
|
|
|
|
accept() {
|
|
if (!this.signature) {
|
|
this._initialResponse = true
|
|
this._protocol.authPKOK(this.key.algo, this.key.data)
|
|
} else {
|
|
AuthContext.prototype.accept.call(this)
|
|
}
|
|
}
|
|
}
|
|
|
|
class HostbasedAuthContext extends AuthContext {
|
|
constructor(protocol, username, service, method, pkInfo, cb) {
|
|
super(protocol, username, service, method, cb)
|
|
|
|
this.key = { algo: pkInfo.keyAlgo, data: pkInfo.key }
|
|
this.hashAlgo = pkInfo.hashAlgo
|
|
this.signature = pkInfo.signature
|
|
this.blob = pkInfo.blob
|
|
this.localHostname = pkInfo.localHostname
|
|
this.localUsername = pkInfo.localUsername
|
|
}
|
|
}
|
|
|
|
class PwdAuthContext extends AuthContext {
|
|
constructor(protocol, username, service, method, password, cb) {
|
|
super(protocol, username, service, method, cb)
|
|
|
|
this.password = password
|
|
this._changeCb = undefined
|
|
}
|
|
|
|
requestChange(prompt, cb) {
|
|
if (this._changeCb) throw new Error('Change request already in progress')
|
|
if (typeof prompt !== 'string') throw new Error('prompt argument must be a string')
|
|
if (typeof cb !== 'function') throw new Error('Callback argument must be a function')
|
|
this._changeCb = cb
|
|
this._protocol.authPasswdChg(prompt)
|
|
}
|
|
}
|
|
|
|
class Session extends EventEmitter {
|
|
constructor(client, info, localChan) {
|
|
super()
|
|
|
|
this.type = 'session'
|
|
this.subtype = undefined
|
|
this.server = true
|
|
this._ending = false
|
|
this._channel = undefined
|
|
this._chanInfo = {
|
|
type: 'session',
|
|
incoming: {
|
|
id: localChan,
|
|
window: MAX_WINDOW,
|
|
packetSize: PACKET_SIZE,
|
|
state: 'open'
|
|
},
|
|
outgoing: {
|
|
id: info.sender,
|
|
window: info.window,
|
|
packetSize: info.packetSize,
|
|
state: 'open'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class Server extends EventEmitter {
|
|
constructor(cfg, listener) {
|
|
super()
|
|
|
|
if (typeof cfg !== 'object' || cfg === null) throw new Error('Missing configuration object')
|
|
|
|
const hostKeys = Object.create(null)
|
|
const hostKeyAlgoOrder = []
|
|
|
|
const hostKeys_ = cfg.hostKeys
|
|
if (!Array.isArray(hostKeys_)) throw new Error('hostKeys must be an array')
|
|
|
|
const cfgAlgos =
|
|
typeof cfg.algorithms === 'object' && cfg.algorithms !== null ? cfg.algorithms : {}
|
|
|
|
const hostKeyAlgos = generateAlgorithmList(
|
|
cfgAlgos.serverHostKey,
|
|
DEFAULT_SERVER_HOST_KEY,
|
|
SUPPORTED_SERVER_HOST_KEY
|
|
)
|
|
for (let i = 0; i < hostKeys_.length; ++i) {
|
|
let privateKey
|
|
if (Buffer.isBuffer(hostKeys_[i]) || typeof hostKeys_[i] === 'string')
|
|
privateKey = parseKey(hostKeys_[i])
|
|
else privateKey = parseKey(hostKeys_[i].key, hostKeys_[i].passphrase)
|
|
|
|
if (privateKey instanceof Error)
|
|
throw new Error(`Cannot parse privateKey: ${privateKey.message}`)
|
|
|
|
if (Array.isArray(privateKey)) {
|
|
// OpenSSH's newer format only stores 1 key for now
|
|
privateKey = privateKey[0]
|
|
}
|
|
|
|
if (privateKey.getPrivatePEM() === null)
|
|
throw new Error('privateKey value contains an invalid private key')
|
|
|
|
// Discard key if we already found a key of the same type
|
|
if (hostKeyAlgoOrder.includes(privateKey.type)) continue
|
|
|
|
if (privateKey.type === 'ssh-rsa') {
|
|
// SSH supports multiple signature hashing algorithms for RSA, so we add
|
|
// the algorithms in the desired order
|
|
let sha1Pos = hostKeyAlgos.indexOf('ssh-rsa')
|
|
const sha256Pos = hostKeyAlgos.indexOf('rsa-sha2-256')
|
|
const sha512Pos = hostKeyAlgos.indexOf('rsa-sha2-512')
|
|
if (sha1Pos === -1) {
|
|
// Fall back to giving SHA1 the lowest priority
|
|
sha1Pos = Infinity
|
|
}
|
|
;[sha1Pos, sha256Pos, sha512Pos].sort(compareNumbers).forEach((pos) => {
|
|
if (pos === -1) return
|
|
|
|
let type
|
|
switch (pos) {
|
|
case sha1Pos:
|
|
type = 'ssh-rsa'
|
|
break
|
|
case sha256Pos:
|
|
type = 'rsa-sha2-256'
|
|
break
|
|
case sha512Pos:
|
|
type = 'rsa-sha2-512'
|
|
break
|
|
default:
|
|
return
|
|
}
|
|
|
|
// Store same RSA key under each hash algorithm name for convenience
|
|
hostKeys[type] = privateKey
|
|
|
|
hostKeyAlgoOrder.push(type)
|
|
})
|
|
} else {
|
|
hostKeys[privateKey.type] = privateKey
|
|
hostKeyAlgoOrder.push(privateKey.type)
|
|
}
|
|
}
|
|
|
|
const algorithms = {
|
|
kex: generateAlgorithmList(cfgAlgos.kex, DEFAULT_KEX, SUPPORTED_KEX).concat([
|
|
'[email protected]'
|
|
]),
|
|
serverHostKey: hostKeyAlgoOrder,
|
|
cs: {
|
|
cipher: generateAlgorithmList(cfgAlgos.cipher, DEFAULT_CIPHER, SUPPORTED_CIPHER),
|
|
mac: generateAlgorithmList(cfgAlgos.hmac, DEFAULT_MAC, SUPPORTED_MAC),
|
|
compress: generateAlgorithmList(
|
|
cfgAlgos.compress,
|
|
DEFAULT_COMPRESSION,
|
|
SUPPORTED_COMPRESSION
|
|
),
|
|
lang: []
|
|
},
|
|
sc: undefined
|
|
}
|
|
algorithms.sc = algorithms.cs
|
|
|
|
if (typeof listener === 'function') this.on('connection', listener)
|
|
|
|
const origDebug = typeof cfg.debug === 'function' ? cfg.debug : undefined
|
|
const ident = cfg.ident ? Buffer.from(cfg.ident) : undefined
|
|
const offer = new KexInit(algorithms)
|
|
|
|
this._srv = new netServer((socket) => {
|
|
if (this._connections >= this.maxConnections) {
|
|
socket.destroy()
|
|
return
|
|
}
|
|
++this._connections
|
|
socket.once('close', () => {
|
|
--this._connections
|
|
})
|
|
|
|
let debug
|
|
if (origDebug) {
|
|
// Prepend debug output with a unique identifier in case there are
|
|
// multiple clients connected at the same time
|
|
const debugPrefix = `[${process.hrtime().join('.')}] `
|
|
debug = (msg) => {
|
|
origDebug(`${debugPrefix}${msg}`)
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line no-use-before-define
|
|
new Client(socket, hostKeys, ident, offer, debug, this, cfg)
|
|
})
|
|
.on('error', (err) => {
|
|
this.emit('error', err)
|
|
})
|
|
.on('listening', () => {
|
|
this.emit('listening')
|
|
})
|
|
.on('close', () => {
|
|
this.emit('close')
|
|
})
|
|
this._connections = 0
|
|
this.maxConnections = Infinity
|
|
}
|
|
|
|
injectSocket(socket) {
|
|
this._srv.emit('connection', socket)
|
|
}
|
|
|
|
listen(...args) {
|
|
this._srv.listen(...args)
|
|
return this
|
|
}
|
|
|
|
address() {
|
|
return this._srv.address()
|
|
}
|
|
|
|
getConnections(cb) {
|
|
this._srv.getConnections(cb)
|
|
return this
|
|
}
|
|
|
|
close(cb) {
|
|
this._srv.close(cb)
|
|
return this
|
|
}
|
|
|
|
ref() {
|
|
this._srv.ref()
|
|
return this
|
|
}
|
|
|
|
unref() {
|
|
this._srv.unref()
|
|
return this
|
|
}
|
|
}
|
|
Server.KEEPALIVE_CLIENT_INTERVAL = 15000
|
|
Server.KEEPALIVE_CLIENT_COUNT_MAX = 3
|
|
|
|
class Client extends EventEmitter {
|
|
constructor(socket, hostKeys, ident, offer, debug, server, srvCfg) {
|
|
super()
|
|
|
|
let exchanges = 0
|
|
let acceptedAuthSvc = false
|
|
let pendingAuths = []
|
|
let authCtx
|
|
let kaTimer
|
|
let onPacket
|
|
const unsentGlobalRequestsReplies = []
|
|
this._sock = socket
|
|
this._chanMgr = new ChannelManager(this)
|
|
this._debug = debug
|
|
this.noMoreSessions = false
|
|
this.authenticated = false
|
|
|
|
// Silence pre-header errors
|
|
function onClientPreHeaderError(err) {}
|
|
this.on('error', onClientPreHeaderError)
|
|
|
|
const DEBUG_HANDLER = !debug
|
|
? undefined
|
|
: (p, display, msg) => {
|
|
debug(`Debug output from client: ${JSON.stringify(msg)}`)
|
|
}
|
|
|
|
const kaIntvl =
|
|
typeof srvCfg.keepaliveInterval === 'number' &&
|
|
isFinite(srvCfg.keepaliveInterval) &&
|
|
srvCfg.keepaliveInterval > 0
|
|
? srvCfg.keepaliveInterval
|
|
: typeof Server.KEEPALIVE_CLIENT_INTERVAL === 'number' &&
|
|
isFinite(Server.KEEPALIVE_CLIENT_INTERVAL) &&
|
|
Server.KEEPALIVE_CLIENT_INTERVAL > 0
|
|
? Server.KEEPALIVE_CLIENT_INTERVAL
|
|
: -1
|
|
const kaCountMax =
|
|
typeof srvCfg.keepaliveCountMax === 'number' &&
|
|
isFinite(srvCfg.keepaliveCountMax) &&
|
|
srvCfg.keepaliveCountMax >= 0
|
|
? srvCfg.keepaliveCountMax
|
|
: typeof Server.KEEPALIVE_CLIENT_COUNT_MAX === 'number' &&
|
|
isFinite(Server.KEEPALIVE_CLIENT_COUNT_MAX) &&
|
|
Server.KEEPALIVE_CLIENT_COUNT_MAX >= 0
|
|
? Server.KEEPALIVE_CLIENT_COUNT_MAX
|
|
: -1
|
|
let kaCurCount = 0
|
|
if (kaIntvl !== -1 && kaCountMax !== -1) {
|
|
this.once('ready', () => {
|
|
const onClose = () => {
|
|
clearInterval(kaTimer)
|
|
}
|
|
this.on('close', onClose).on('end', onClose)
|
|
kaTimer = setInterval(() => {
|
|
if (++kaCurCount > kaCountMax) {
|
|
clearInterval(kaTimer)
|
|
const err = new Error('Keepalive timeout')
|
|
err.level = 'client-timeout'
|
|
this.emit('error', err)
|
|
this.end()
|
|
} else {
|
|
// XXX: if the server ever starts sending real global requests to
|
|
// the client, we will need to add a dummy callback here to
|
|
// keep the correct reply order
|
|
proto.ping()
|
|
}
|
|
}, kaIntvl)
|
|
})
|
|
// TODO: re-verify keepalive behavior with OpenSSH
|
|
onPacket = () => {
|
|
kaTimer && kaTimer.refresh()
|
|
kaCurCount = 0
|
|
}
|
|
}
|
|
|
|
const proto = (this._protocol = new Protocol({
|
|
server: true,
|
|
hostKeys,
|
|
ident,
|
|
offer,
|
|
onPacket,
|
|
greeting: srvCfg.greeting,
|
|
banner: srvCfg.banner,
|
|
onWrite: (data) => {
|
|
if (isWritable(socket)) socket.write(data)
|
|
},
|
|
onError: (err) => {
|
|
if (!proto._destruct) socket.removeAllListeners('data')
|
|
this.emit('error', err)
|
|
try {
|
|
socket.end()
|
|
} catch {}
|
|
},
|
|
onHeader: (header) => {
|
|
this.removeListener('error', onClientPreHeaderError)
|
|
|
|
const info = {
|
|
ip: socket.remoteAddress,
|
|
family: socket.remoteFamily,
|
|
port: socket.remotePort,
|
|
header
|
|
}
|
|
if (!server.emit('connection', this, info)) {
|
|
// auto reject
|
|
proto.disconnect(DISCONNECT_REASON.BY_APPLICATION)
|
|
socket.end()
|
|
return
|
|
}
|
|
|
|
if (header.greeting) this.emit('greeting', header.greeting)
|
|
},
|
|
onHandshakeComplete: (negotiated) => {
|
|
if (++exchanges > 1) this.emit('rekey')
|
|
this.emit('handshake', negotiated)
|
|
},
|
|
debug,
|
|
messageHandlers: {
|
|
DEBUG: DEBUG_HANDLER,
|
|
DISCONNECT: (p, reason, desc) => {
|
|
if (reason !== DISCONNECT_REASON.BY_APPLICATION) {
|
|
if (!desc) {
|
|
desc = DISCONNECT_REASON_BY_VALUE[reason]
|
|
if (desc === undefined) desc = `Unexpected disconnection reason: ${reason}`
|
|
}
|
|
const err = new Error(desc)
|
|
err.code = reason
|
|
this.emit('error', err)
|
|
}
|
|
socket.end()
|
|
},
|
|
CHANNEL_OPEN: (p, info) => {
|
|
// Handle incoming requests from client
|
|
|
|
// Do early reject in some cases to prevent wasteful channel
|
|
// allocation
|
|
if ((info.type === 'session' && this.noMoreSessions) || !this.authenticated) {
|
|
const reasonCode = CHANNEL_OPEN_FAILURE.ADMINISTRATIVELY_PROHIBITED
|
|
return proto.channelOpenFail(info.sender, reasonCode)
|
|
}
|
|
|
|
let localChan = -1
|
|
let reason
|
|
let replied = false
|
|
|
|
let accept
|
|
const reject = () => {
|
|
if (replied) return
|
|
replied = true
|
|
|
|
if (reason === undefined) {
|
|
if (localChan === -1) reason = CHANNEL_OPEN_FAILURE.RESOURCE_SHORTAGE
|
|
else reason = CHANNEL_OPEN_FAILURE.CONNECT_FAILED
|
|
}
|
|
|
|
if (localChan !== -1) this._chanMgr.remove(localChan)
|
|
proto.channelOpenFail(info.sender, reason, '')
|
|
}
|
|
const reserveChannel = () => {
|
|
localChan = this._chanMgr.add()
|
|
|
|
if (localChan === -1) {
|
|
reason = CHANNEL_OPEN_FAILURE.RESOURCE_SHORTAGE
|
|
if (debug) {
|
|
debug('Automatic rejection of incoming channel open: ' + 'no channels available')
|
|
}
|
|
}
|
|
|
|
return localChan !== -1
|
|
}
|
|
|
|
const data = info.data
|
|
switch (info.type) {
|
|
case 'session':
|
|
if (listenerCount(this, 'session') && reserveChannel()) {
|
|
accept = () => {
|
|
if (replied) return
|
|
replied = true
|
|
|
|
const instance = new Session(this, info, localChan)
|
|
this._chanMgr.update(localChan, instance)
|
|
|
|
proto.channelOpenConfirm(info.sender, localChan, MAX_WINDOW, PACKET_SIZE)
|
|
|
|
return instance
|
|
}
|
|
|
|
this.emit('session', accept, reject)
|
|
return
|
|
}
|
|
break
|
|
case 'direct-tcpip':
|
|
if (listenerCount(this, 'tcpip') && reserveChannel()) {
|
|
accept = () => {
|
|
if (replied) return
|
|
replied = true
|
|
|
|
const chanInfo = {
|
|
type: undefined,
|
|
incoming: {
|
|
id: localChan,
|
|
window: MAX_WINDOW,
|
|
packetSize: PACKET_SIZE,
|
|
state: 'open'
|
|
},
|
|
outgoing: {
|
|
id: info.sender,
|
|
window: info.window,
|
|
packetSize: info.packetSize,
|
|
state: 'open'
|
|
}
|
|
}
|
|
|
|
const stream = new Channel(this, chanInfo, { server: true })
|
|
this._chanMgr.update(localChan, stream)
|
|
|
|
proto.channelOpenConfirm(info.sender, localChan, MAX_WINDOW, PACKET_SIZE)
|
|
|
|
return stream
|
|
}
|
|
|
|
this.emit('tcpip', accept, reject, data)
|
|
return
|
|
}
|
|
break
|
|
case '[email protected]':
|
|
if (listenerCount(this, 'openssh.streamlocal') && reserveChannel()) {
|
|
accept = () => {
|
|
if (replied) return
|
|
replied = true
|
|
|
|
const chanInfo = {
|
|
type: undefined,
|
|
incoming: {
|
|
id: localChan,
|
|
window: MAX_WINDOW,
|
|
packetSize: PACKET_SIZE,
|
|
state: 'open'
|
|
},
|
|
outgoing: {
|
|
id: info.sender,
|
|
window: info.window,
|
|
packetSize: info.packetSize,
|
|
state: 'open'
|
|
}
|
|
}
|
|
|
|
const stream = new Channel(this, chanInfo, { server: true })
|
|
this._chanMgr.update(localChan, stream)
|
|
|
|
proto.channelOpenConfirm(info.sender, localChan, MAX_WINDOW, PACKET_SIZE)
|
|
|
|
return stream
|
|
}
|
|
|
|
this.emit('openssh.streamlocal', accept, reject, data)
|
|
return
|
|
}
|
|
break
|
|
default:
|
|
// Automatically reject any unsupported channel open requests
|
|
reason = CHANNEL_OPEN_FAILURE.UNKNOWN_CHANNEL_TYPE
|
|
if (debug) {
|
|
debug(
|
|
'Automatic rejection of unsupported incoming channel open' + ` type: ${info.type}`
|
|
)
|
|
}
|
|
}
|
|
|
|
if (reason === undefined) {
|
|
reason = CHANNEL_OPEN_FAILURE.ADMINISTRATIVELY_PROHIBITED
|
|
if (debug) {
|
|
debug(
|
|
'Automatic rejection of unexpected incoming channel open' + ` for: ${info.type}`
|
|
)
|
|
}
|
|
}
|
|
|
|
reject()
|
|
},
|
|
CHANNEL_OPEN_CONFIRMATION: (p, info) => {
|
|
const channel = this._chanMgr.get(info.recipient)
|
|
if (typeof channel !== 'function') return
|
|
|
|
const chanInfo = {
|
|
type: channel.type,
|
|
incoming: {
|
|
id: info.recipient,
|
|
window: MAX_WINDOW,
|
|
packetSize: PACKET_SIZE,
|
|
state: 'open'
|
|
},
|
|
outgoing: {
|
|
id: info.sender,
|
|
window: info.window,
|
|
packetSize: info.packetSize,
|
|
state: 'open'
|
|
}
|
|
}
|
|
|
|
const instance = new Channel(this, chanInfo, { server: true })
|
|
this._chanMgr.update(info.recipient, instance)
|
|
channel(undefined, instance)
|
|
},
|
|
CHANNEL_OPEN_FAILURE: (p, recipient, reason, description) => {
|
|
const channel = this._chanMgr.get(recipient)
|
|
if (typeof channel !== 'function') return
|
|
|
|
const info = { reason, description }
|
|
onChannelOpenFailure(this, recipient, info, channel)
|
|
},
|
|
CHANNEL_DATA: (p, recipient, data) => {
|
|
let channel = this._chanMgr.get(recipient)
|
|
if (typeof channel !== 'object' || channel === null) return
|
|
|
|
if (channel.constructor === Session) {
|
|
channel = channel._channel
|
|
if (!channel) return
|
|
}
|
|
|
|
// The remote party should not be sending us data if there is no
|
|
// window space available ...
|
|
// TODO: raise error on data with not enough window?
|
|
if (channel.incoming.window === 0) return
|
|
|
|
channel.incoming.window -= data.length
|
|
|
|
if (channel.push(data) === false) {
|
|
channel._waitChanDrain = true
|
|
return
|
|
}
|
|
|
|
if (channel.incoming.window <= WINDOW_THRESHOLD) windowAdjust(channel)
|
|
},
|
|
CHANNEL_EXTENDED_DATA: (p, recipient, data, type) => {
|
|
// NOOP -- should not be sent by client
|
|
},
|
|
CHANNEL_WINDOW_ADJUST: (p, recipient, amount) => {
|
|
let channel = this._chanMgr.get(recipient)
|
|
if (typeof channel !== 'object' || channel === null) return
|
|
|
|
if (channel.constructor === Session) {
|
|
channel = channel._channel
|
|
if (!channel) return
|
|
}
|
|
|
|
// The other side is allowing us to send `amount` more bytes of data
|
|
channel.outgoing.window += amount
|
|
|
|
if (channel._waitWindow) {
|
|
channel._waitWindow = false
|
|
|
|
if (channel._chunk) {
|
|
channel._write(channel._chunk, null, channel._chunkcb)
|
|
} else if (channel._chunkcb) {
|
|
channel._chunkcb()
|
|
} else if (channel._chunkErr) {
|
|
channel.stderr._write(channel._chunkErr, null, channel._chunkcbErr)
|
|
} else if (channel._chunkcbErr) {
|
|
channel._chunkcbErr()
|
|
}
|
|
}
|
|
},
|
|
CHANNEL_SUCCESS: (p, recipient) => {
|
|
let channel = this._chanMgr.get(recipient)
|
|
if (typeof channel !== 'object' || channel === null) return
|
|
|
|
if (channel.constructor === Session) {
|
|
channel = channel._channel
|
|
if (!channel) return
|
|
}
|
|
|
|
if (channel._callbacks.length) channel._callbacks.shift()(false)
|
|
},
|
|
CHANNEL_FAILURE: (p, recipient) => {
|
|
let channel = this._chanMgr.get(recipient)
|
|
if (typeof channel !== 'object' || channel === null) return
|
|
|
|
if (channel.constructor === Session) {
|
|
channel = channel._channel
|
|
if (!channel) return
|
|
}
|
|
|
|
if (channel._callbacks.length) channel._callbacks.shift()(true)
|
|
},
|
|
CHANNEL_REQUEST: (p, recipient, type, wantReply, data) => {
|
|
const session = this._chanMgr.get(recipient)
|
|
if (typeof session !== 'object' || session === null) return
|
|
|
|
let replied = false
|
|
let accept
|
|
let reject
|
|
|
|
if (session.constructor !== Session) {
|
|
// normal Channel instance
|
|
if (wantReply) proto.channelFailure(session.outgoing.id)
|
|
return
|
|
}
|
|
|
|
if (wantReply) {
|
|
// "real session" requests will have custom accept behaviors
|
|
if (type !== 'shell' && type !== 'exec' && type !== 'subsystem') {
|
|
accept = () => {
|
|
if (replied || session._ending || session._channel) return
|
|
replied = true
|
|
|
|
proto.channelSuccess(session._chanInfo.outgoing.id)
|
|
}
|
|
}
|
|
|
|
reject = () => {
|
|
if (replied || session._ending || session._channel) return
|
|
replied = true
|
|
|
|
proto.channelFailure(session._chanInfo.outgoing.id)
|
|
}
|
|
}
|
|
|
|
if (session._ending) {
|
|
reject && reject()
|
|
return
|
|
}
|
|
|
|
switch (type) {
|
|
// "pre-real session start" requests
|
|
case 'env':
|
|
if (listenerCount(session, 'env')) {
|
|
session.emit('env', accept, reject, {
|
|
key: data.name,
|
|
val: data.value
|
|
})
|
|
return
|
|
}
|
|
break
|
|
case 'pty-req':
|
|
if (listenerCount(session, 'pty')) {
|
|
session.emit('pty', accept, reject, data)
|
|
return
|
|
}
|
|
break
|
|
case 'window-change':
|
|
if (listenerCount(session, 'window-change'))
|
|
session.emit('window-change', accept, reject, data)
|
|
else reject && reject()
|
|
break
|
|
case 'x11-req':
|
|
if (listenerCount(session, 'x11')) {
|
|
session.emit('x11', accept, reject, data)
|
|
return
|
|
}
|
|
break
|
|
// "post-real session start" requests
|
|
case 'signal':
|
|
if (listenerCount(session, 'signal')) {
|
|
session.emit('signal', accept, reject, {
|
|
name: data
|
|
})
|
|
return
|
|
}
|
|
break
|
|
// XXX: is `[email protected]` really "post-real session
|
|
// start"?
|
|
case '[email protected]':
|
|
if (listenerCount(session, 'auth-agent')) {
|
|
session.emit('auth-agent', accept, reject)
|
|
return
|
|
}
|
|
break
|
|
// "real session start" requests
|
|
case 'shell':
|
|
if (listenerCount(session, 'shell')) {
|
|
accept = () => {
|
|
if (replied || session._ending || session._channel) return
|
|
replied = true
|
|
|
|
if (wantReply) proto.channelSuccess(session._chanInfo.outgoing.id)
|
|
|
|
const channel = new Channel(this, session._chanInfo, { server: true })
|
|
|
|
channel.subtype = session.subtype = type
|
|
session._channel = channel
|
|
|
|
return channel
|
|
}
|
|
|
|
session.emit('shell', accept, reject)
|
|
return
|
|
}
|
|
break
|
|
case 'exec':
|
|
if (listenerCount(session, 'exec')) {
|
|
accept = () => {
|
|
if (replied || session._ending || session._channel) return
|
|
replied = true
|
|
|
|
if (wantReply) proto.channelSuccess(session._chanInfo.outgoing.id)
|
|
|
|
const channel = new Channel(this, session._chanInfo, { server: true })
|
|
|
|
channel.subtype = session.subtype = type
|
|
session._channel = channel
|
|
|
|
return channel
|
|
}
|
|
|
|
session.emit('exec', accept, reject, {
|
|
command: data
|
|
})
|
|
return
|
|
}
|
|
break
|
|
case 'subsystem': {
|
|
let useSFTP = data === 'sftp'
|
|
accept = () => {
|
|
if (replied || session._ending || session._channel) return
|
|
replied = true
|
|
|
|
if (wantReply) proto.channelSuccess(session._chanInfo.outgoing.id)
|
|
|
|
let instance
|
|
if (useSFTP) {
|
|
instance = new SFTP(this, session._chanInfo, {
|
|
server: true,
|
|
debug
|
|
})
|
|
} else {
|
|
instance = new Channel(this, session._chanInfo, { server: true })
|
|
instance.subtype = session.subtype = `${type}:${data}`
|
|
}
|
|
session._channel = instance
|
|
|
|
return instance
|
|
}
|
|
|
|
if (data === 'sftp') {
|
|
if (listenerCount(session, 'sftp')) {
|
|
session.emit('sftp', accept, reject)
|
|
return
|
|
}
|
|
useSFTP = false
|
|
}
|
|
if (listenerCount(session, 'subsystem')) {
|
|
session.emit('subsystem', accept, reject, {
|
|
name: data
|
|
})
|
|
return
|
|
}
|
|
break
|
|
}
|
|
}
|
|
debug && debug(`Automatic rejection of incoming channel request: ${type}`)
|
|
reject && reject()
|
|
},
|
|
CHANNEL_EOF: (p, recipient) => {
|
|
let channel = this._chanMgr.get(recipient)
|
|
if (typeof channel !== 'object' || channel === null) return
|
|
|
|
if (channel.constructor === Session) {
|
|
if (!channel._ending) {
|
|
channel._ending = true
|
|
channel.emit('eof')
|
|
channel.emit('end')
|
|
}
|
|
channel = channel._channel
|
|
if (!channel) return
|
|
}
|
|
|
|
if (channel.incoming.state !== 'open') return
|
|
channel.incoming.state = 'eof'
|
|
|
|
if (channel.readable) channel.push(null)
|
|
},
|
|
CHANNEL_CLOSE: (p, recipient) => {
|
|
let channel = this._chanMgr.get(recipient)
|
|
if (typeof channel !== 'object' || channel === null) return
|
|
|
|
if (channel.constructor === Session) {
|
|
channel._ending = true
|
|
channel.emit('close')
|
|
channel = channel._channel
|
|
if (!channel) return
|
|
}
|
|
|
|
onCHANNEL_CLOSE(this, recipient, channel)
|
|
},
|
|
// Begin service/auth-related ==========================================
|
|
SERVICE_REQUEST: (p, service) => {
|
|
if (
|
|
exchanges === 0 ||
|
|
acceptedAuthSvc ||
|
|
this.authenticated ||
|
|
service !== 'ssh-userauth'
|
|
) {
|
|
proto.disconnect(DISCONNECT_REASON.SERVICE_NOT_AVAILABLE)
|
|
socket.end()
|
|
return
|
|
}
|
|
|
|
acceptedAuthSvc = true
|
|
proto.serviceAccept(service)
|
|
},
|
|
USERAUTH_REQUEST: (p, username, service, method, methodData) => {
|
|
if (
|
|
exchanges === 0 ||
|
|
this.authenticated ||
|
|
(authCtx && (authCtx.username !== username || authCtx.service !== service)) ||
|
|
// TODO: support hostbased auth
|
|
(method !== 'password' &&
|
|
method !== 'publickey' &&
|
|
method !== 'hostbased' &&
|
|
method !== 'keyboard-interactive' &&
|
|
method !== 'none') ||
|
|
pendingAuths.length === MAX_PENDING_AUTHS
|
|
) {
|
|
proto.disconnect(DISCONNECT_REASON.PROTOCOL_ERROR)
|
|
socket.end()
|
|
return
|
|
} else if (service !== 'ssh-connection') {
|
|
proto.disconnect(DISCONNECT_REASON.SERVICE_NOT_AVAILABLE)
|
|
socket.end()
|
|
return
|
|
}
|
|
|
|
let ctx
|
|
switch (method) {
|
|
case 'keyboard-interactive':
|
|
ctx = new KeyboardAuthContext(
|
|
proto,
|
|
username,
|
|
service,
|
|
method,
|
|
methodData,
|
|
onAuthDecide
|
|
)
|
|
break
|
|
case 'publickey':
|
|
ctx = new PKAuthContext(proto, username, service, method, methodData, onAuthDecide)
|
|
break
|
|
case 'hostbased':
|
|
ctx = new HostbasedAuthContext(
|
|
proto,
|
|
username,
|
|
service,
|
|
method,
|
|
methodData,
|
|
onAuthDecide
|
|
)
|
|
break
|
|
case 'password':
|
|
if (authCtx && authCtx instanceof PwdAuthContext && authCtx._changeCb) {
|
|
const cb = authCtx._changeCb
|
|
authCtx._changeCb = undefined
|
|
cb(methodData.newPassword)
|
|
return
|
|
}
|
|
ctx = new PwdAuthContext(proto, username, service, method, methodData, onAuthDecide)
|
|
break
|
|
case 'none':
|
|
ctx = new AuthContext(proto, username, service, method, onAuthDecide)
|
|
break
|
|
}
|
|
|
|
if (authCtx) {
|
|
if (!authCtx._initialResponse) {
|
|
return pendingAuths.push(ctx)
|
|
} else if (authCtx._multistep && !authCtx._finalResponse) {
|
|
// RFC 4252 says to silently abort the current auth request if a
|
|
// new auth request comes in before the final response from an
|
|
// auth method that requires additional request/response exchanges
|
|
// -- this means keyboard-interactive for now ...
|
|
authCtx._cleanup && authCtx._cleanup()
|
|
authCtx.emit('abort')
|
|
}
|
|
}
|
|
|
|
authCtx = ctx
|
|
|
|
if (listenerCount(this, 'authentication')) this.emit('authentication', authCtx)
|
|
else authCtx.reject()
|
|
},
|
|
USERAUTH_INFO_RESPONSE: (p, responses) => {
|
|
if (authCtx && authCtx instanceof KeyboardAuthContext) authCtx._onInfoResponse(responses)
|
|
},
|
|
// End service/auth-related ============================================
|
|
GLOBAL_REQUEST: (p, name, wantReply, data) => {
|
|
const reply = {
|
|
type: null,
|
|
buf: null
|
|
}
|
|
|
|
function setReply(type, buf) {
|
|
reply.type = type
|
|
reply.buf = buf
|
|
sendReplies()
|
|
}
|
|
|
|
if (wantReply) unsentGlobalRequestsReplies.push(reply)
|
|
|
|
if (
|
|
(name === 'tcpip-forward' ||
|
|
name === 'cancel-tcpip-forward' ||
|
|
name === '[email protected]' ||
|
|
name === '[email protected]' ||
|
|
name === '[email protected]') &&
|
|
listenerCount(this, 'request') &&
|
|
this.authenticated
|
|
) {
|
|
let accept
|
|
let reject
|
|
|
|
if (wantReply) {
|
|
let replied = false
|
|
accept = (chosenPort) => {
|
|
if (replied) return
|
|
replied = true
|
|
let bufPort
|
|
if (
|
|
name === 'tcpip-forward' &&
|
|
data.bindPort === 0 &&
|
|
typeof chosenPort === 'number'
|
|
) {
|
|
bufPort = Buffer.allocUnsafe(4)
|
|
writeUInt32BE(bufPort, chosenPort, 0)
|
|
}
|
|
setReply('SUCCESS', bufPort)
|
|
}
|
|
reject = () => {
|
|
if (replied) return
|
|
replied = true
|
|
setReply('FAILURE')
|
|
}
|
|
}
|
|
|
|
if (name === '[email protected]') {
|
|
this.noMoreSessions = true
|
|
accept && accept()
|
|
return
|
|
}
|
|
|
|
this.emit('request', accept, reject, name, data)
|
|
} else if (wantReply) {
|
|
setReply('FAILURE')
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
|
|
socket.pause()
|
|
cryptoInit
|
|
.then(() => {
|
|
proto.start()
|
|
socket.on('data', (data) => {
|
|
try {
|
|
proto.parse(data, 0, data.length)
|
|
} catch (ex) {
|
|
this.emit('error', ex)
|
|
try {
|
|
if (isWritable(socket)) socket.end()
|
|
} catch {}
|
|
}
|
|
})
|
|
socket.resume()
|
|
})
|
|
.catch((err) => {
|
|
this.emit('error', err)
|
|
try {
|
|
if (isWritable(socket)) socket.end()
|
|
} catch {}
|
|
})
|
|
socket
|
|
.on('error', (err) => {
|
|
err.level = 'socket'
|
|
this.emit('error', err)
|
|
})
|
|
.once('end', () => {
|
|
debug && debug('Socket ended')
|
|
proto.cleanup()
|
|
this.emit('end')
|
|
})
|
|
.once('close', () => {
|
|
debug && debug('Socket closed')
|
|
proto.cleanup()
|
|
this.emit('close')
|
|
|
|
const err = new Error('No response from server')
|
|
|
|
// Simulate error for pending channels and close any open channels
|
|
this._chanMgr.cleanup(err)
|
|
})
|
|
|
|
const onAuthDecide = (ctx, allowed, methodsLeft, isPartial) => {
|
|
if (authCtx === ctx && !this.authenticated) {
|
|
if (allowed) {
|
|
authCtx = undefined
|
|
this.authenticated = true
|
|
proto.authSuccess()
|
|
pendingAuths = []
|
|
this.emit('ready')
|
|
} else {
|
|
proto.authFailure(methodsLeft, isPartial)
|
|
if (pendingAuths.length) {
|
|
authCtx = pendingAuths.pop()
|
|
if (listenerCount(this, 'authentication')) this.emit('authentication', authCtx)
|
|
else authCtx.reject()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function sendReplies() {
|
|
while (unsentGlobalRequestsReplies.length > 0 && unsentGlobalRequestsReplies[0].type) {
|
|
const reply = unsentGlobalRequestsReplies.shift()
|
|
if (reply.type === 'SUCCESS') proto.requestSuccess(reply.buf)
|
|
if (reply.type === 'FAILURE') proto.requestFailure()
|
|
}
|
|
}
|
|
}
|
|
|
|
end() {
|
|
if (this._sock && isWritable(this._sock)) {
|
|
this._protocol.disconnect(DISCONNECT_REASON.BY_APPLICATION)
|
|
this._sock.end()
|
|
}
|
|
return this
|
|
}
|
|
|
|
x11(originAddr, originPort, cb) {
|
|
const opts = { originAddr, originPort }
|
|
openChannel(this, 'x11', opts, cb)
|
|
return this
|
|
}
|
|
|
|
forwardOut(boundAddr, boundPort, remoteAddr, remotePort, cb) {
|
|
const opts = { boundAddr, boundPort, remoteAddr, remotePort }
|
|
openChannel(this, 'forwarded-tcpip', opts, cb)
|
|
return this
|
|
}
|
|
|
|
openssh_forwardOutStreamLocal(socketPath, cb) {
|
|
const opts = { socketPath }
|
|
openChannel(this, '[email protected]', opts, cb)
|
|
return this
|
|
}
|
|
|
|
rekey(cb) {
|
|
let error
|
|
|
|
try {
|
|
this._protocol.rekey()
|
|
} catch (ex) {
|
|
error = ex
|
|
}
|
|
|
|
// TODO: re-throw error if no callback?
|
|
|
|
if (typeof cb === 'function') {
|
|
if (error) process.nextTick(cb, error)
|
|
else this.once('rekey', cb)
|
|
}
|
|
}
|
|
|
|
setNoDelay(noDelay) {
|
|
if (this._sock && typeof this._sock.setNoDelay === 'function') this._sock.setNoDelay(noDelay)
|
|
|
|
return this
|
|
}
|
|
}
|
|
|
|
function openChannel(self, type, opts, cb) {
|
|
// Ask the client to open a channel for some purpose (e.g. a forwarded TCP
|
|
// connection)
|
|
const initWindow = MAX_WINDOW
|
|
const maxPacket = PACKET_SIZE
|
|
|
|
if (typeof opts === 'function') {
|
|
cb = opts
|
|
opts = {}
|
|
}
|
|
|
|
const wrapper = (err, stream) => {
|
|
cb(err, stream)
|
|
}
|
|
wrapper.type = type
|
|
|
|
const localChan = self._chanMgr.add(wrapper)
|
|
|
|
if (localChan === -1) {
|
|
cb(new Error('No free channels available'))
|
|
return
|
|
}
|
|
|
|
switch (type) {
|
|
case 'forwarded-tcpip':
|
|
self._protocol.forwardedTcpip(localChan, initWindow, maxPacket, opts)
|
|
break
|
|
case 'x11':
|
|
self._protocol.x11(localChan, initWindow, maxPacket, opts)
|
|
break
|
|
case '[email protected]':
|
|
self._protocol.openssh_forwardedStreamLocal(localChan, initWindow, maxPacket, opts)
|
|
break
|
|
default:
|
|
throw new Error(`Unsupported channel type: ${type}`)
|
|
}
|
|
}
|
|
|
|
function compareNumbers(a, b) {
|
|
return a - b
|
|
}
|
|
|
|
module.exports = Server
|
|
module.exports.IncomingClient = Client
|