151 lines
5.6 KiB
JavaScript
151 lines
5.6 KiB
JavaScript
// native imports
|
|
import { promisify } from 'util'
|
|
import readline from 'readline'
|
|
import { dirname } from 'path'
|
|
import { readFile } from 'fs'
|
|
const read = promisify(readFile)
|
|
// third party imports
|
|
import to from 'await-to-js'
|
|
import { readFile as readConfig } from 'fs-read-data'
|
|
import { Client } from 'ssh2'
|
|
import logger from '@uci-utils/logger'
|
|
import Conf from 'conf'
|
|
let log = {} // declare module wide log to be set during construction
|
|
|
|
// ssh client is already an event emitter
|
|
class Ssh extends Client {
|
|
constructor(opts={}) {
|
|
super()
|
|
this.opts = opts
|
|
log = logger({id: opts.id })
|
|
this.ready = false
|
|
this.config = new Conf({projectName:'ssh-uci'})
|
|
this.configsDir = process.env.SSH_CONFIG_DIR || this.config.get('configsDir') || dirname(this.config.path)
|
|
log.debug({configsDir:this.configsDir, msg:'configuration files directory'})
|
|
this.rpath = opts.rpath || '/opt' // can be used to make all commands run in a particular remote path
|
|
}
|
|
|
|
async configure(opts,overwrite) {
|
|
let configPath
|
|
if (typeof opts ==='boolean' || !opts ) {
|
|
if (typeof opts ==='boolean') overwrite = opts
|
|
configPath = this.configsDir+'/'+this.config.get('defaultConfig')
|
|
opts = await readConfig(configPath)
|
|
}
|
|
if (typeof opts === 'string') {
|
|
configPath = (opts.indexOf('/') === -1)? this.configsDir+'/'+opts : opts
|
|
opts = await readConfig(configPath)
|
|
}
|
|
|
|
log.debug({configPath:this.configPath, msg:'path to actual configuration file used'})
|
|
log.debug({opts:opts, msg:'connection options as read in from file'})
|
|
this.opts = overwrite ? Object.assign(this.opts,opts) : Object.assign(opts, this.opts)
|
|
log.debug({opts:opts, msg:'connection options as ammended from configure() argument'})
|
|
}
|
|
|
|
async connect(copts = {}) {
|
|
const opts = Object.assign({},this.opts,copts) // merge any changes pasted via connect
|
|
log.debug({opts:opts, msg:'final connection options as ammend via connect() argument and as used'})
|
|
if (opts.agentenv) opts.agent = process.env[opts.agentenv]
|
|
opts.readyTimeout = opts.readyTimeout | 5000 // default was 20 seconds...too long
|
|
opts.privateKey = opts.privateKey || opts.privateKeyPath ? await read(opts.privateKeyPath) : null
|
|
// log.info(opts.privateKeyPath, opts.privateKey)
|
|
super.connect(opts)
|
|
return new Promise( (resolve,reject) => {
|
|
this.on('ready', async () => {
|
|
this.ready=true
|
|
log.info(`connected to ${this.opts.host}`)
|
|
resolve(`connected to ${this.opts.host}`)
|
|
})
|
|
this.on('error', (err) => {
|
|
log.warn({err:err, opts:this.opts, msg:'connection error'})
|
|
reject(err)
|
|
})
|
|
})
|
|
}
|
|
|
|
close() {
|
|
this.ready=false
|
|
log.info('closing ssh connection')
|
|
this.end()
|
|
}
|
|
|
|
async shell(command, opts={}) {
|
|
if (Object.prototype.toString.call(command) === '[object Object]') {
|
|
opts = command
|
|
if (!opts.cli) {
|
|
this.close()
|
|
let err = new Error('no terminal interactive shell must pass a command')
|
|
log.fatal({command:command, opts:opts, err:err, msg:'no terminal interactive shell must pass a command'})
|
|
return err
|
|
}
|
|
command = [] // a place to store all terminal commands
|
|
}
|
|
opts.shell=true
|
|
return this.exec(command,opts)
|
|
}
|
|
|
|
async exec(cmd = 'ls -la', opts={}) {
|
|
if (!this.ready) {
|
|
log.info('not connected, attempting connect first')
|
|
let [err] = await to(this.connect())
|
|
if (err) return err
|
|
}
|
|
const remote = this // TODO bind this on promise instead
|
|
const superexec = opts.shell? super.shell.bind(this) : super.exec.bind(this)
|
|
let command = opts.shell ? opts : cmd
|
|
// log.info(`executing command ${command} on shell? ${opts.shell}`)
|
|
return new Promise(function(resolve, reject) {
|
|
let reply =''
|
|
let error = ''
|
|
superexec(command, function(err, stream) {
|
|
log.info({command:command, cmd:cmd, opts:opts, msg:'before ssh exec/shell'})
|
|
const done = () => {
|
|
if (error) remote.emit('remote:error',error)
|
|
resolve({command:command,cmds:cmd,reply:reply,error:error})
|
|
}
|
|
if (err) reject(err)
|
|
stream.once('close', () => {
|
|
if (remote.opts.close || opts.close || opts.cli) {
|
|
remote.on('end', done)
|
|
remote.close()
|
|
} else done()
|
|
}).on('data', function(data) {
|
|
reply += data.toString()
|
|
if (opts.cli) process.stdout.write(data.toString())
|
|
else log.info(`${remote.opts.host}$ ${data}`)
|
|
}).stderr.on('data', function(data) {
|
|
error += data.toString()
|
|
if (opts.cli) process.stderr.write(data.toString())
|
|
else log.info(`${remote.opts.host}:ERR $: ${data}`)
|
|
})
|
|
// alternate shell processing
|
|
if (opts.shell) {
|
|
if (opts.cli) { // terminal interactive shell sesssion
|
|
const cli= readline.createInterface({input:process.stdin, output:process.stdout})
|
|
cli.on('line', line => {
|
|
cmd.push(line)
|
|
if(line.trim() === 'exit') {
|
|
cli.removeAllListeners()
|
|
cli.close()
|
|
stream.end(`${line}\n`)
|
|
} else {
|
|
stream.write(`${line}\n`)
|
|
}
|
|
}) // end terminal listener
|
|
}
|
|
else {
|
|
// give a bit of time for MOTD to display so it's not part of reply
|
|
setTimeout(() => {
|
|
reply=''
|
|
stream.end(`${cmd}\nexit\n`)
|
|
}, opts.delay || 300)
|
|
}
|
|
} // end shell processing
|
|
}) // end super
|
|
}) // end Promise
|
|
}
|
|
}
|
|
|
|
export default Ssh
|