Skip to content

Commit c67246e

Browse files
session: Add 'open_session_retries' option
Improve pylibssh handling when libssh ssh_channel_open_session() returns SSH_AGAIN. Add a new 'open_session_retries' session connect() parameter to allow a configurable number of retries. SSH_AGAIN may be returned when setting a low SSH options timeout value. The default option value is 0, no retries will be attempted.
1 parent f7e5329 commit c67246e

File tree

6 files changed

+86
-11
lines changed

6 files changed

+86
-11
lines changed

src/pylibsshext/channel.pyx

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ from libc.string cimport memset
2424

2525
from pylibsshext.errors cimport LibsshChannelException
2626
from pylibsshext.errors import LibsshChannelReadFailure
27-
from pylibsshext.session cimport get_libssh_session
27+
from pylibsshext.session cimport get_libssh_session, get_session_retries
2828

2929
from subprocess import CompletedProcess
3030

@@ -63,12 +63,20 @@ cdef class Channel:
6363

6464
if self._libssh_channel is NULL:
6565
raise MemoryError
66-
rc = libssh.ssh_channel_open_session(self._libssh_channel)
6766

68-
if rc != libssh.SSH_OK:
69-
libssh.ssh_channel_free(self._libssh_channel)
70-
self._libssh_channel = NULL
71-
raise LibsshChannelException("Failed to open_session: [%d]" % rc)
67+
retry = get_session_retries(session)
68+
69+
for attempt in range(retry + 1):
70+
rc = libssh.ssh_channel_open_session(self._libssh_channel)
71+
if rc == libssh.SSH_OK:
72+
break
73+
if rc == libssh.SSH_AGAIN and attempt < retry:
74+
continue
75+
# either SSH_ERROR, or SSH_AGAIN with final attempt
76+
if rc != libssh.SSH_OK:
77+
libssh.ssh_channel_free(self._libssh_channel)
78+
self._libssh_channel = NULL
79+
raise LibsshChannelException("Failed to open_session: [%d]" % rc)
7280

7381
def __dealloc__(self):
7482
if self._libssh_channel is not NULL:
@@ -164,10 +172,18 @@ cdef class Channel:
164172
if channel is NULL:
165173
raise MemoryError
166174

167-
rc = libssh.ssh_channel_open_session(channel)
168-
if rc != libssh.SSH_OK:
169-
libssh.ssh_channel_free(channel)
170-
raise LibsshChannelException("Failed to open_session: [{0}]".format(rc))
175+
retry = get_session_retries(self._session)
176+
177+
for attempt in range(retry + 1):
178+
rc = libssh.ssh_channel_open_session(channel)
179+
if rc == libssh.SSH_OK:
180+
break
181+
if rc == libssh.SSH_AGAIN and attempt < retry:
182+
continue
183+
# either SSH_ERROR, or SSH_AGAIN with final attempt
184+
if rc != libssh.SSH_OK:
185+
libssh.ssh_channel_free(channel)
186+
raise LibsshChannelException("Failed to open_session: [{0}]".format(rc))
171187

172188
result = CompletedProcess(args=command, returncode=-1, stdout=b'', stderr=b'')
173189

src/pylibsshext/session.pxd

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ cdef class Session:
2626
cdef _hash_py
2727
cdef _fingerprint_py
2828
cdef _keytype_py
29+
cdef _retries
2930
cdef _channel_callbacks
3031

3132
cdef libssh.ssh_session get_libssh_session(Session session)
33+
cdef int get_session_retries(Session session)

src/pylibsshext/session.pyx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ cdef class Session(object):
109109
self._hash_py = None
110110
self._fingerprint_py = None
111111
self._keytype_py = None
112+
self._retries = 0
112113
# Due to delayed freeing of channels, some older libssh versions might expect
113114
# the callbacks to be around even after we free the underlying channels so
114115
# we should free them only when we terminate the session.
@@ -236,9 +237,17 @@ cdef class Session(object):
236237
file should be validated. It defaults to True
237238
:type host_key_checking: boolean
238239
240+
:param open_session_retries: The number of retries to attempt when libssh
241+
channel function ssh_channel_open_session() returns SSH_AGAIN. It defaults
242+
to 0, no retries attempted.
243+
:type open_session_retries: integer
244+
239245
:param timeout: The timeout in seconds for the TCP connect
240246
:type timeout: long integer
241247
248+
:param timeout_usec: The timeout in microseconds for the TCP connect
249+
:type timeout_usec: long integer
250+
242251
:param port: The ssh server port to connect to
243252
:type port: integer
244253
@@ -262,6 +271,9 @@ cdef class Session(object):
262271
libssh.ssh_disconnect(self._libssh_session)
263272
raise
264273

274+
if kwargs.get('open_session_retries'):
275+
self._retries = kwargs.get('open_session_retries')
276+
265277
# We need to userauth_none before we can query the available auth types
266278
rc = libssh.ssh_userauth_none(self._libssh_session, NULL)
267279
if rc == libssh.SSH_AUTH_SUCCESS:
@@ -554,3 +566,6 @@ cdef class Session(object):
554566

555567
cdef libssh.ssh_session get_libssh_session(Session session):
556568
return session._libssh_session
569+
570+
cdef int get_session_retries(Session session):
571+
return session._retries

tests/_service_utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def wait_for_svc_ready_state(
6969

7070
def ensure_ssh_session_connected( # noqa: WPS317
7171
ssh_session, sshd_addr, ssh_clientkey_path, # noqa: WPS318
72+
ssh_session_retries=0
7273
):
7374
"""Attempt connecting to the SSH server until successful.
7475
@@ -88,5 +89,5 @@ def ensure_ssh_session_connected( # noqa: WPS317
8889
user=getpass.getuser(),
8990
private_key=ssh_clientkey_path.read_bytes(),
9091
host_key_checking=False,
91-
look_for_keys=False,
92+
open_session_retries=ssh_session_retries,
9293
)

tests/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,24 @@ def ssh_client_session(ssh_session_connect):
126126
del ssh_session # noqa: WPS420
127127

128128

129+
@pytest.fixture
130+
def ssh_session_connect_retries(sshd_addr, ssh_clientkey_path):
131+
"""
132+
Authenticate existing session object against SSHD with a private SSH key
133+
with ssh_session_retries parameter set to '10'
134+
135+
It returns a function that takes session as parameter.
136+
137+
:returns: Function that will connect the session.
138+
:rtype: Callback
139+
"""
140+
return partial(
141+
ensure_ssh_session_connected,
142+
sshd_addr=sshd_addr,
143+
ssh_clientkey_path=ssh_clientkey_path,
144+
ssh_session_retries=10,
145+
)
146+
129147
@pytest.fixture
130148
def ssh_session_connect(sshd_addr, ssh_clientkey_path):
131149
"""

tests/unit/channel_test.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import pytest
1010

11+
from pylibsshext.errors import LibsshChannelException
1112
from pylibsshext.session import Session
1213

1314

@@ -32,6 +33,28 @@ def ssh_channel(ssh_client_session):
3233
finally:
3334
chan.close()
3435

36+
def test_open_session_timeout(ssh_session_connect):
37+
"""Test that setting a low timeout value generates an
38+
exception from ssh_channel_open_session() with default
39+
open_session_retries value of 0.
40+
"""
41+
ssh_session = Session()
42+
ssh_session_connect(ssh_session)
43+
ssh_session.set_ssh_options("timeout_usec", 10000)
44+
error_msg = '^Failed to open_session'
45+
with pytest.raises(LibsshChannelException, match=error_msg):
46+
ssh_channel = ssh_session.new_channel()
47+
48+
49+
def test_open_session_with_retries(ssh_session_connect_retries):
50+
"""Test with a low timeout value and 'open_session_retries=10'
51+
set, ssh_channel_open_session() will succeed.
52+
"""
53+
ssh_session = Session()
54+
ssh_session_connect_retries(ssh_session)
55+
ssh_session.set_ssh_options("timeout_usec", 10000)
56+
ssh_channel = ssh_session.new_channel()
57+
3558

3659
def exec_second_command(ssh_channel):
3760
"""Check the standard output of ``exec_command()`` as a string."""

0 commit comments

Comments
 (0)