domotz-remote-pawn
Version:
Domotz Agent
389 lines (337 loc) • 14.5 kB
JavaScript
/** This file is part of Domotz Agent.
* Copyright (C) 2020 Domotz Ltd
*
* Domotz Agent is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Domotz Agent is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Domotz Agent. If not, see <http://www.gnu.org/licenses/>.
*
* Created by Andrea Azzara <a.azzara@domotz.com> on 6/04/2020.
*/
module.exports.factory = function (resourceLocator) {
var myConsole = resourceLocator.log.decorateLogs();
var Client = resourceLocator.ssh2.Client;
var sleep = resourceLocator.q.delay;
var async = resourceLocator.async;
var eventEmitter = resourceLocator.events.EventEmitter;
function sshShellSequence(options, callback) {
var generalOptions = {
prompt: options.prompt || '#',
promptRegex: options.prompt_regex ? new RegExp(options.prompt_regex) : null,
errorPrompt: options.error_prompt,
haltOn: options.halt_on || [],
interCommandTimeoutMs: options.inter_command_timeout_ms || 1000,
globalTimeoutMs: options.global_timeout_ms || 30000,
port: options.port || 22,
};
var prompt = generalOptions.prompt;
var promptRegex = generalOptions.promptRegex;
var errorPrompt = generalOptions.errorPrompt;
var haltOn = generalOptions.haltOn;
var interCommandTimeoutMs = generalOptions.interCommandTimeoutMs;
var globalTimeoutMs = generalOptions.globalTimeoutMs;
var commands = options.commands;
var useSystemSSHShellSequence = false;
if (resourceLocator.settingsManager) {
useSystemSSHShellSequence = resourceLocator.settingsManager.useSystemSSHShellSequence();
}
var systemSSHEnabled = useSystemSSHShellSequence || options.use_system_ssh;
if (systemSSHEnabled) {
var systemSSH = resourceLocator.networkTools.systemSSH;
return systemSSH.sshShellSequence(options, callback, generalOptions);
}
var sshOptions = {
host: options.host,
username: options.username,
port: options.port || 22,
password: options.password,
debug: function (debugMessages) {
myConsole.verbose(debugMessages);
},
};
if (resourceLocator.settingsManager) {
var sshAlgorithms = resourceLocator.settingsManager.getSSHConfiguration();
sshOptions.algorithms = {
kex: sshAlgorithms.kex,
cipher: sshAlgorithms.cipher,
};
}
if (options.forced_algorithms) {
myConsole.info('Forcing sshOption.algorithms from cloud');
sshOptions.algorithms = {
kex: options.forced_algorithms.kex,
cipher: options.forced_algorithms.cipher,
};
}
if (options.keyboard_interactive) {
sshOptions.tryKeyboard = options.keyboard_interactive;
}
myConsole.debug('sshOptions: ' + JSON.stringify(sshOptions));
var callbackCalled = false;
var outputEvent = new eventEmitter();
var conn = new Client();
function executeOnStream(stream, command) {
myConsole.info('executing command: ' + command);
stream.write(command + '\n');
}
function returnError(e) {
if (!callbackCalled) {
myConsole.warn('Error ' + JSON.stringify(e));
callbackCalled = true;
callback({
error: e,
output: null,
});
}
}
function isCorrectPrompt(promptChunk, promptOutput) {
return (
promptChunk.search(promptRegex) > -1 ||
promptOutput.search(promptRegex) > -1 ||
promptChunk.indexOf(prompt) > -1 ||
promptOutput.indexOf(prompt) > -1
);
}
function isErroneousPrompt(promptChunk, promptOutput) {
return errorPrompt && (promptChunk.indexOf(errorPrompt) > -1 || promptOutput.indexOf(errorPrompt) > -1);
}
conn.on('error', function (e) {
myConsole.warn('ssh connection error ' + e.message);
returnError({ message: e.message });
});
conn.on('end', function () {
returnError({ message: 'disconnected' });
});
conn.on('close', function () {
returnError({ message: 'socket closed' });
});
conn.on('keyboard-interactive', function (name, instr, lang, prompts, cb) {
cb([options.password]);
});
conn.on('ready', function () {
myConsole.debug('ssh Client ready');
var output = '';
var resetOutput = function () {
output = '';
};
var timeout = setTimeout(function () {
returnError({ message: 'timeout' });
}, globalTimeoutMs);
conn.shell(function (err, stream) {
if (err) {
myConsole.warn('stream error' + err.toString());
return;
}
stream.on('readable', function () {
var chunk;
while (null !== (chunk = stream.read())) {
output += chunk;
if (chunk.length > 0) {
var chunkString = chunk.toString();
if (isCorrectPrompt(chunkString, output)) {
outputEvent.emit('shellReady', output);
resetOutput();
} else if (isErroneousPrompt(chunkString, output)) {
outputEvent.emit('shellInError', output);
resetOutput();
}
}
}
});
stream.on('close', function () {
myConsole.debug('Stream was closed successfully');
outputEvent.emit('shellReady', 'Stream Closed');
conn.end();
});
var commandFunctions = [];
var singleCommandExecutor = function (command, cb) {
var localCallbackCalled = false;
function cbWrapper(error, value) {
if (!localCallbackCalled) {
localCallbackCalled = true;
cb(error, value);
}
}
outputEvent.once('shellInError', function (commandErrorResult) {
myConsole.debug('Shell Resulted in Error: %s', commandErrorResult);
cbWrapper({ message: commandErrorResult, command: command }, null);
});
sleep(interCommandTimeoutMs).then(function () {
resetOutput();
executeOnStream(stream, command);
outputEvent.once('shellReady', function (commandResult) {
var b64Output = Buffer(commandResult).toString('base64');
var halt = haltOn.filter(function (stopString) {
return commandResult.indexOf(stopString) > -1;
});
if (halt.length > 0) {
cbWrapper({ message: 'halted on', command: command, halted_on: b64Output });
return;
}
myConsole.info('command %s completed', command);
myConsole.verbose('command %s output: ', commandResult);
cbWrapper(null, b64Output);
});
});
};
commands.forEach(function (item) {
commandFunctions.push(singleCommandExecutor.bind(null, item));
});
async.series(commandFunctions, function (err, result) {
stream.end();
conn.end();
if (err && !callbackCalled) {
returnError(err);
} else {
myConsole.info('Sequence done');
myConsole.verbose(JSON.stringify(result));
if (!callbackCalled) {
clearTimeout(timeout);
callbackCalled = true;
callback({
error: null,
output: result,
});
} else {
myConsole.warn('sequence complete but timeout expired, result is ignored');
}
}
});
});
});
conn.connect(sshOptions);
}
function executor(command, payload, myConsole, deferred) {
var e = {
callbackCalled: false,
timeout: payload.timeout || payload.ttl || 2000,
sshOptions: {
host: payload.host,
username: payload.username || payload.user,
port: payload.port || 22,
password: payload.password,
algorithms: payload.algorithms,
},
connection: new Client(),
timeoutTimer: null,
_stdout: '',
_stderr: '',
};
if (payload.keyboard_interactive) {
e.sshOptions.tryKeyboard = payload.keyboard_interactive;
}
myConsole.debug('Options: ' + JSON.stringify(e.sshOptions));
// Entry point - only method to be called outside tests
e.start = function () {
var conn = e.connection;
conn.on('ready', e._executeCommand);
conn.on('error', e._handleConnectionError);
conn.on('keyboard-interactive', function (name, instr, lang, prompts, cb) {
cb([payload.password]);
});
e.timeoutTimer = setTimeout(e._killForTimeout, e.timeout);
conn.connect(e.sshOptions);
};
e._executeCommand = function () {
console.info('Connection to host %s established', e.sshOptions.host);
e.connection.exec(command, e._onCommandRunning);
};
e._onCommandRunning = function (err, stream) {
if (err) {
e._end(undefined, err);
return;
}
stream.on('close', e._onCommandExited);
stream.on('data', e._stdoutArrived);
stream.stderr.on('data', e._stderrArrived);
};
e._stdoutArrived = function (data) {
e._stdout += data;
};
e._stderrArrived = function (data) {
e._stderr += data;
};
e._onCommandExited = function (code, signal) {
var conn = e.connection;
myConsole.debug('Execution terminated: exit code=' + code + ', signal=' + signal);
conn.end();
if (e.callbackCalled) {
myConsole.info('Callback already called, probably due to a timeout, ignoring outcome');
return;
}
var stdout = (e._stdout || '').trim();
var stderr = (e._stderr || '').trim();
if (code === 0) {
e._end(stdout, undefined);
} else {
var exception = new Error(stderr);
exception.code = code;
exception.output = stdout;
e._end(undefined, exception);
}
};
e._handleConnectionError = function (error) {
myConsole.warn("SSH Connection error: '%s'", error.message);
e._transformErrorForWorker(error);
e._end(undefined, error);
};
e._transformErrorForWorker = function (error) {
// Calculate the error code that ssh returns in similar situations
if (error.message === 'All configured authentication methods failed') {
error.code = 5;
error.message = 'Permission denied';
} else {
error.code =
{
ECONNREFUSED: 255,
}[error.message] || 255;
}
};
e._killForTimeout = function () {
e._end(undefined, new Error('Timeout of ' + e.timeout + 'ms expired'));
e.connection.end();
};
e._end = function (output, error) {
if (e.callbackCalled) {
myConsole.debug('Duplicate call to ssh exec end: output=%s, error=%s', output, error);
return;
}
e.callbackCalled = true;
clearTimeout(e.timeoutTimer);
if (error) {
deferred.reject(error);
} else {
deferred.resolve(output);
}
};
return e;
}
/**
* Executes a command via ssh on a remote host
* @param command - the command to execute
* @param payload - options such as host, username, password etc.
* @param correlationId - unique id of the message, for trace logging
* @returns promise
*/
function exec(command, payload, correlationId) {
var localConsole = myConsole.decorate(correlationId);
localConsole.info("Executing command: '%s'", command);
var deferred = resourceLocator.q.defer();
executor(command, payload, localConsole, deferred).start();
return deferred.promise;
}
return {
sshShellSequence: sshShellSequence,
exec: exec,
// Testing purpose
_executor: executor,
};
};