Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 77 additions & 88 deletions openwisp_controller/connection/connectors/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,37 +17,37 @@

class Ssh(object):
schema = {
'$schema': 'http://json-schema.org/draft-04/schema#',
'type': 'object',
'title': 'Credentials type',
'oneOf': [
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"title": "Credentials type",
"oneOf": [
{
'title': 'SSH (password)',
'required': ['username', 'password'],
'additionalProperties': False,
'properties': {
'username': {'type': 'string', 'minLength': 2},
'password': {'type': 'string', 'minLength': 4},
'port': {
'type': 'integer',
'default': 22,
'minimum': 1,
'maximum': 65535,
"title": "SSH (password)",
"required": ["username", "password"],
"additionalProperties": False,
"properties": {
"username": {"type": "string", "minLength": 2},
"password": {"type": "string", "minLength": 4},
"port": {
"type": "integer",
"default": 22,
"minimum": 1,
"maximum": 65535,
},
},
},
{
'title': 'SSH (private key)',
'required': ['username', 'key'],
'additionalProperties': False,
'properties': {
'username': {'type': 'string'},
'key': {'type': 'string', 'format': 'textarea', 'minLength': 64},
'port': {
'type': 'number',
'default': 22,
'minimum': 1,
'maximum': 65535,
"title": "SSH (private key)",
"required": ["username", "key"],
"additionalProperties": False,
"properties": {
"username": {"type": "string"},
"key": {"type": "string", "format": "textarea", "minLength": 64},
"port": {
"type": "number",
"default": 22,
"minimum": 1,
"maximum": 65535,
},
},
},
Expand All @@ -67,18 +67,18 @@ def validate(cls, params):
validate(params, cls.schema)
cls.custom_validation(params)
# trigger SSH key algorithm check
cls(params, ['127.0.0.1']).params
cls(params, ["127.0.0.1"]).params

@classmethod
def custom_validation(cls, params):
if 'password' not in params and 'key' not in params:
raise SchemaError('Missing password or key')
if "password" not in params and "key" not in params:
raise SchemaError("Missing password or key")

@cached_property
def params(self):
params = self._params.copy()
if 'key' in params:
params['pkey'] = self._get_ssh_key(params.pop('key'))
if "key" in params:
params["pkey"] = self._get_ssh_key(params.pop("key"))
return params

@property
Expand All @@ -99,15 +99,15 @@ def _get_ssh_key(self, key):
]
for key_algo in key_algorithms:
try:
return getattr(key_algo, 'from_private_key')(key_fileobj)
return getattr(key_algo, "from_private_key")(key_fileobj)
except (paramiko.ssh_exception.SSHException, ValueError):
key_fileobj.seek(0)
continue
else:
raise SchemaError(
_(
'Unrecognized or unsupported SSH key algorithm, '
'only RSA and ED25519 are currently supported.'
"Unrecognized or unsupported SSH key algorithm, "
"only RSA and ED25519 are currently supported."
)
)

Expand All @@ -116,7 +116,7 @@ def connect(self):
exception = None
addresses = self.addresses
if not addresses:
raise ValueError('No valid IP addresses to initiate connections found')
raise ValueError("No valid IP addresses to initiate connections found")
if self.is_connected:
# Do not establish a new connection if
# a connection was already established.
Expand Down Expand Up @@ -155,9 +155,9 @@ def _connect(self, address):
# described at https://github.com/paramiko/paramiko/issues/1961
# let's retry by disabling the new default HostKeyAlgorithms,
# which can work on older systems.
if e.args == ('Authentication failed.',) and attempt == 1:
params['disabled_algorithms'] = {
'pubkeys': ['rsa-sha2-512', 'rsa-sha2-256']
if e.args == ("Authentication failed.",) and attempt == 1:
params["disabled_algorithms"] = {
"pubkeys": ["rsa-sha2-512", "rsa-sha2-256"]
}
self.shell.close()
continue
Expand All @@ -169,62 +169,51 @@ def disconnect(self):
self.shell.close()

def exec_command(
self,
command,
timeout=app_settings.SSH_COMMAND_TIMEOUT,
exit_codes=[0],
raise_unexpected_exit=True,
):
"""
Executes a command and performs the following operations
- logs executed command
- logs standard output
- logs standard error
- aborts on exceptions
- raises socket.timeout exceptions
"""
logger.info('Executing command: {0}'.format(command))
# execute commmand
try:
stdin, stdout, stderr = self.shell.exec_command(command, timeout=timeout)
# re-raise socket.timeout to avoid being catched
# by the subsequent `except Exception as e` block
except socket.timeout:
raise socket.timeout()
# any other exception will abort the operation
except Exception as e:
logger.exception(e)
raise e
# store command exit status
exit_status = stdout.channel.recv_exit_status()
# log standard output
# try to decode to UTF-8, ignoring unconvertible characters
# https://docs.python.org/3/howto/unicode.html#the-string-type
output = stdout.read().decode('utf-8', 'ignore')
if output:
logger.info(output)
# log standard error
error = stderr.read().decode('utf-8', 'ignore')
if error:
if not output.endswith('\n'):
output += '\n'
output += error
# abort the operation if any of the command
# returned with a non-zero exit status
if exit_status not in exit_codes and raise_unexpected_exit:
log_message = 'Unexpected exit code: {0}'.format(exit_status)
logger.info(log_message)
message = error if error else output
# if message is empty, use log_message
raise CommandFailedException(message or log_message)
return output, exit_status
self,
command,
timeout=app_settings.SSH_COMMAND_TIMEOUT,
exit_codes=[0],
raise_unexpected_exit=True,
):
logger.info('Executing command: {0}'.format(command))
try:
stdin, stdout, stderr = self.shell.exec_command(command, timeout=timeout)
except socket.timeout:
raise socket.timeout()
except (socket.timeout, paramiko.ssh_exception.NoValidConnectionsError, paramiko.ssh_exception.SSHException):
# Do not log known SSH or network errors
raise
except Exception as e:
# Log only unexpected exceptions
logger.exception("Unexpected SSH command exception")
raise

exit_status = stdout.channel.recv_exit_status()
output = stdout.read().decode('utf-8', 'ignore')
if output:
logger.info(output)

error = stderr.read().decode('utf-8', 'ignore')
if error:
if not output.endswith('\n'):
output += '\n'
output += error

if exit_status not in exit_codes and raise_unexpected_exit:
log_message = 'Unexpected exit code: {0}'.format(exit_status)
logger.info(log_message)
message = error if error else output
raise CommandFailedException(message or log_message)

return output, exit_status


def update_config(self): # pragma: no cover
raise NotImplementedError()

def upload(self, fl, remote_path):
scp = SCPClient(self.shell.get_transport())
if not hasattr(fl, 'getvalue'):
if not hasattr(fl, "getvalue"):
fl_memory = BytesIO(fl.read())
fl.seek(0)
fl = fl_memory
Expand Down
Loading