Skip to content

Commit 962afbe

Browse files
asmodehnAlexandre Vincentnemesifier
authored
[fix] Enforced command timeout
The SSH library Paramiko was not enforcing the command timeout setting, a workaround was implemented to ensure the timeout is respected in OpenWISP Controller. Addresses openwisp/openwisp-firmware-upgrader#244 (comment) --------- Co-authored-by: Alexandre Vincent <[email protected]> Co-authored-by: Federico Capoano <[email protected]>
1 parent 9b1bf61 commit 962afbe

File tree

5 files changed

+45
-6
lines changed

5 files changed

+45
-6
lines changed

openwisp_controller/connection/connectors/exceptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,11 @@ class CommandFailedException(Exception):
44
"""
55

66
pass
7+
8+
9+
class CommandTimeoutException(Exception):
10+
"""
11+
raised when a command times out
12+
"""
13+
14+
pass

openwisp_controller/connection/connectors/ssh.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import socket
3+
import time
34
from io import BytesIO, StringIO
45

56
import paramiko
@@ -10,7 +11,7 @@
1011
from scp import SCPClient
1112

1213
from .. import settings as app_settings
13-
from .exceptions import CommandFailedException
14+
from .exceptions import CommandFailedException, CommandTimeoutException
1415

1516
logger = logging.getLogger(__name__)
1617

@@ -148,7 +149,7 @@ def _connect(self, address):
148149
auth_timeout=app_settings.SSH_AUTH_TIMEOUT,
149150
banner_timeout=app_settings.SSH_BANNER_TIMEOUT,
150151
timeout=app_settings.SSH_CONNECTION_TIMEOUT,
151-
**params
152+
**params,
152153
)
153154
except paramiko.ssh_exception.AuthenticationException as e:
154155
# the authentication failure may be caused by the issue
@@ -183,9 +184,12 @@ def exec_command(
183184
- aborts on exceptions
184185
- raises socket.timeout exceptions
185186
"""
187+
# paramiko expects timeout as a float
188+
timeout = float(timeout)
186189
logger.info("Executing command: {0}".format(command))
187190
# execute commmand
188191
try:
192+
start_cmd = time.perf_counter()
189193
stdin, stdout, stderr = self.shell.exec_command(command, timeout=timeout)
190194
# re-raise socket.timeout to avoid being catched
191195
# by the subsequent `except Exception as e` block
@@ -195,8 +199,16 @@ def exec_command(
195199
except Exception as e:
196200
logger.exception(e)
197201
raise e
198-
# store command exit status
199-
exit_status = stdout.channel.recv_exit_status()
202+
# workaround https://github.com/paramiko/paramiko/issues/1815
203+
# workaround https://github.com/paramiko/paramiko/issues/1787
204+
# Ref. https://docs.paramiko.org/en/stable/api/channel.html#paramiko.channel.Channel.recv_exit_status # noqa
205+
if not stdout.channel.status_event.wait(
206+
timeout=timeout - (time.perf_counter() - start_cmd)
207+
):
208+
log_message = f"Command timed out after {timeout} seconds."
209+
logger.info(log_message)
210+
raise CommandTimeoutException(log_message)
211+
exit_status = stdout.channel.exit_status
200212
# log standard output
201213
# try to decode to UTF-8, ignoring unconvertible characters
202214
# https://docs.python.org/3/howto/unicode.html#the-string-type

openwisp_controller/connection/tasks.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from swapper import load_model
1010

1111
from . import settings as app_settings
12+
from .connectors.exceptions import CommandTimeoutException
1213
from .exceptions import NoWorkingDeviceConnectionError
1314

1415
logger = logging.getLogger(__name__)
@@ -76,6 +77,10 @@ def launch_command(command_id):
7677
command.status = "failed"
7778
command._add_output(_("Background task time limit exceeded."))
7879
command.save()
80+
except CommandTimeoutException as e:
81+
command.status = "failed"
82+
command._add_output(_(f"The command took longer than expected: {e}"))
83+
command.save()
7984
except Exception as e:
8085
logger.exception(
8186
f"An exception was raised while executing command {command_id}"

openwisp_controller/connection/tests/test_models.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import socket
22
from unittest import mock
3+
from unittest.mock import PropertyMock
34

45
import paramiko
56
from django.contrib.auth.models import ContentType
@@ -46,7 +47,7 @@ def _exec_command_return_value(
4647
stderr_ = mock.Mock()
4748
stdin_.read().decode.return_value = stdin
4849
stdout_.read().decode.return_value = stdout
49-
stdout_.channel.recv_exit_status.return_value = exit_code
50+
type(stdout_.channel).exit_status = PropertyMock(return_value=exit_code)
5051
stderr_.read().decode.return_value = stderr
5152
return (stdin_, stdout_, stderr_)
5253

@@ -1009,7 +1010,7 @@ def _assert_applying_conf_test_command(mocked_exec):
10091010
# 1. Checking openwisp_config returns with 0
10101011
# 2. Testing presence of /tmp/openwisp/applying_conf returns with 1
10111012
# 3. Restarting openwisp_config returns with 0 exit code
1012-
stdout.channel.recv_exit_status.side_effect = [0, 1, 1]
1013+
type(stdout.channel).exit_status = PropertyMock(side_effect=[0, 1, 1])
10131014
mocked_exec_command.return_value = (stdin, stdout, stderr)
10141015
conf.save()
10151016
self.assertEqual(mocked_exec_command.call_count, 3)

openwisp_controller/connection/tests/test_ssh.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ def test_connection_failed_command(self, mocked_debug, mocked_info):
7070
]
7171
)
7272

73+
@mock.patch.object(ssh_logger, "info")
74+
@mock.patch.object(ssh_logger, "debug")
75+
def test_connection_command_timeout(self, mocked_debug, mocked_info):
76+
ckey = self._create_credentials_with_key(port=self.ssh_server.port)
77+
dc = self._create_device_connection(credentials=ckey)
78+
dc.connector_instance.connect()
79+
with self.assertRaises(Exception) as ctx:
80+
# timeout of 0.0 is a special case in paramiko -> we check for 0.01 instead
81+
dc.connector_instance.exec_command("sleep 1", timeout=0.01)
82+
log_message = "Command timed out after 0.01 seconds."
83+
mocked_info.assert_has_calls([mock.call(log_message)])
84+
self.assertEqual(str(ctx.exception), log_message)
85+
7386
@mock.patch.object(ssh_logger, "info")
7487
@mock.patch.object(ssh_logger, "debug")
7588
def test_connection_failed_command_suppressed_output(

0 commit comments

Comments
 (0)