codecraftai-protocol-ssh 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ccai/protocols/ssh/__init__.py +3 -0
- ccai/protocols/ssh/py.typed +0 -0
- ccai/protocols/ssh/sshagent.py +375 -0
- ccai/protocols/ssh/sshauth.py +118 -0
- ccai/protocols/ssh/sshbase.py +780 -0
- ccai/protocols/ssh/sshclient.py +170 -0
- ccai/protocols/ssh/sshconnection.py +1285 -0
- ccai/protocols/ssh/sshconst.py +49 -0
- ccai/protocols/ssh/sshconstants.py +185 -0
- ccai/protocols/ssh/sshhelpers.py +695 -0
- ccai/protocols/ssh/sshkeys.py +197 -0
- ccai/protocols/ssh/sshpacket.py +138 -0
- ccai/protocols/ssh/sshsession.py +433 -0
- ccai/protocols/ssh/sshsftp.py +297 -0
- ccai/protocols/ssh/sshtransport.py +756 -0
- ccai/protocols/ssh/sshtunnel.py +464 -0
- codecraftai_protocol_ssh-1.3.0.dist-info/METADATA +98 -0
- codecraftai_protocol_ssh-1.3.0.dist-info/RECORD +20 -0
- codecraftai_protocol_ssh-1.3.0.dist-info/WHEEL +4 -0
- codecraftai_protocol_ssh-1.3.0.dist-info/licenses/LICENSE.md +204 -0
|
File without changes
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""
|
|
2
|
+
.. module:: sshagent
|
|
3
|
+
:platform: Darwin, Linux, Unix, Windows
|
|
4
|
+
:synopsis: Module containing the :class:`SshAgent` class.
|
|
5
|
+
|
|
6
|
+
.. moduleauthor:: Myron Walker <myron.walker@gmail.com>
|
|
7
|
+
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__author__ = "Myron Walker"
|
|
11
|
+
__copyright__ = "Copyright 2025, Code Craft AI LLC"
|
|
12
|
+
__credits__ = []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
from typing import Dict, Optional, Sequence, Union, Tuple
|
|
16
|
+
|
|
17
|
+
from ccai.xmodules.xlogging.enhancedloggers import getEnhancedLogger
|
|
18
|
+
|
|
19
|
+
from ccai.protocols.ssh.sshclient import SshClient
|
|
20
|
+
import socket
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from ccai.interop.aspects import AspectsCmd, DEFAULT_CMD_ASPECTS
|
|
24
|
+
from ccai.credentials.sshcredential import SshCredential
|
|
25
|
+
|
|
26
|
+
from ccai.protocols.ssh.sshbase import SshBase
|
|
27
|
+
from ccai.protocols.ssh.sshconst import (
|
|
28
|
+
SshJumpParams
|
|
29
|
+
)
|
|
30
|
+
from ccai.protocols.ssh.sshsession import SshSession
|
|
31
|
+
from ccai.protocols.ssh.sshtunnel import SshTunnel
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
logger = getEnhancedLogger()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SshAgent(SshBase):
|
|
38
|
+
"""
|
|
39
|
+
The :class:`SshAgent` provides an extension of the :class:`SshBase` class interface and api(s). The :class:`SshAgent` manages connection
|
|
40
|
+
and interop settings that are used to perform operations and interact with a remote SSH service. The :class:`SshAgent` provides standard
|
|
41
|
+
APIs for running command and transferring files along with code that helps ensure commands are logged cleanly. The :class:`SshAgent` also
|
|
42
|
+
provides run patterning to help eliminate duplication of code associated with running SSH commands in loops.
|
|
43
|
+
"""
|
|
44
|
+
def __init__(self, host: str, primary_credential: SshCredential, users: Optional[Dict[str, SshCredential]] = None, port: int = 22, jump: Union[str, SshJumpParams, None] = None,
|
|
45
|
+
pty_params: Optional[dict] = None, called_id: Optional[str]=None, look_for_keys: bool = False, aspects: AspectsCmd = DEFAULT_CMD_ASPECTS):
|
|
46
|
+
"""
|
|
47
|
+
Initialize an :class:`SshAgent` instance.
|
|
48
|
+
|
|
49
|
+
:param host: Hostname or IP address of the target.
|
|
50
|
+
:param primary_credential: Primary SSH credential.
|
|
51
|
+
:param users: Optional dictionary of additional credentials.
|
|
52
|
+
:param port: SSH port number.
|
|
53
|
+
:param jump: Optional jump host settings.
|
|
54
|
+
:param pty_params: Parameters used when requesting a PTY.
|
|
55
|
+
:param called_id: Friendly identifier for logging purposes.
|
|
56
|
+
:param look_for_keys: Search for SSH keys in default locations.
|
|
57
|
+
:param aspects: Default command aspects for operations.
|
|
58
|
+
"""
|
|
59
|
+
SshBase.__init__(self, host, primary_credential, users=users, port=port, jump=jump,
|
|
60
|
+
pty_params=pty_params, called_id=called_id, look_for_keys=look_for_keys, aspects=aspects)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
def open_session(self, user: str = None, primitive: bool = False, pty_params: Optional[dict] = None, interactive=False, basis_session: Optional[SshSession] = None,
|
|
64
|
+
aspects: Optional[AspectsCmd] = None) -> SshSession:
|
|
65
|
+
"""
|
|
66
|
+
Provies a mechanism to create a :class:`SshSession` object with derived settings. This method allows various parameters for the session
|
|
67
|
+
to be overridden. This allows for the performing of a series of SSH operations under a particular set of shared settings and or credentials.
|
|
68
|
+
|
|
69
|
+
:param user: open the session under a different user credential.
|
|
70
|
+
:param primitive: Use primitive mode for FTP operations for the session.
|
|
71
|
+
:param pty_params: The default pty parameters to use to request a PTY when running commands through the session.
|
|
72
|
+
:param interactive: Creates an interactive session which holds open an interactive shell so commands can interact in the shell.
|
|
73
|
+
:param basis_session: An optional SshSession instance to use as a session basis. This allows re-use of sessions.
|
|
74
|
+
:param aspects: The default run aspects to use for the operations performed by the session.
|
|
75
|
+
|
|
76
|
+
:returns: A new :class:`SshSession` instance with the requested parameters.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
if aspects is None:
|
|
80
|
+
aspects = self._aspects
|
|
81
|
+
|
|
82
|
+
session_credential = self._primary_credential
|
|
83
|
+
if user is not None:
|
|
84
|
+
session_credential = self._users[user]
|
|
85
|
+
|
|
86
|
+
session = None
|
|
87
|
+
if basis_session is not None:
|
|
88
|
+
bs = basis_session
|
|
89
|
+
session = SshSession(bs._host, session_credential, users=bs._users, port=bs._port,
|
|
90
|
+
jump=bs._jump, pty_params=pty_params, interactive=interactive,
|
|
91
|
+
basis_session=basis_session, look_for_keys=bs._look_for_keys, primitive=primitive, aspects=aspects)
|
|
92
|
+
else:
|
|
93
|
+
session = SshSession(self._host, session_credential, users=self._users, port=self._port,
|
|
94
|
+
jump=self._jump, pty_params=pty_params, interactive=interactive,
|
|
95
|
+
look_for_keys=self._look_for_keys, primitive=primitive, aspects=aspects)
|
|
96
|
+
return session
|
|
97
|
+
|
|
98
|
+
def open_tunnel(self, local_port: int = 0, remote_host: str = 'localhost', remote_port: int = 5432) -> SshTunnel:
|
|
99
|
+
"""
|
|
100
|
+
Creates a single SSH port forwarding tunnel to a remote service. This is the ONLY way to create
|
|
101
|
+
SSH tunnels. Each tunnel represents exactly one port forward. To create multiple tunnels,
|
|
102
|
+
call this method multiple times.
|
|
103
|
+
|
|
104
|
+
:param local_port: Local port to bind for forwarding. If 0, an available port will be chosen.
|
|
105
|
+
:param remote_host: Remote host to forward to (default: localhost on remote machine).
|
|
106
|
+
:param remote_port: Remote port to forward to (default: 5432 for PostgreSQL).
|
|
107
|
+
|
|
108
|
+
:returns: A new :class:`SshTunnel` instance representing a single port forward with an established SSH connection.
|
|
109
|
+
|
|
110
|
+
:raises: RuntimeError if tunnel creation fails.
|
|
111
|
+
:raises: ConnectionError if SSH connection cannot be established.
|
|
112
|
+
"""
|
|
113
|
+
# Create and establish the SSH connection
|
|
114
|
+
ssh_client = self._create_client()
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
# Create the tunnel using the internal creation method
|
|
118
|
+
tunnel = SshTunnel._create_internal(ssh_client, local_port, remote_host, remote_port)
|
|
119
|
+
return tunnel
|
|
120
|
+
except Exception as e:
|
|
121
|
+
# If tunnel creation fails, clean up the SSH client
|
|
122
|
+
try:
|
|
123
|
+
ssh_client.close()
|
|
124
|
+
except Exception:
|
|
125
|
+
# Ignore errors when closing the client
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
# Re-raise the original exception with additional context
|
|
129
|
+
raise RuntimeError(f"Failed to create SSH tunnel to {remote_host}:{remote_port}: {e}") from e
|
|
130
|
+
|
|
131
|
+
def reboot(self, aspects: Optional[AspectsCmd] = None):
|
|
132
|
+
"""
|
|
133
|
+
Reboots the designated host by running the 'reboot' command on the host.
|
|
134
|
+
|
|
135
|
+
..note: This method is not a typical SSH functionality, but it was added so that
|
|
136
|
+
automatic reconnects can be implemented as required on session objects.
|
|
137
|
+
|
|
138
|
+
"""
|
|
139
|
+
reboot_cmd = 'reboot'
|
|
140
|
+
try:
|
|
141
|
+
self.run_cmd(reboot_cmd, aspects=aspects)
|
|
142
|
+
except (ConnectionError, socket.error, EOFError) as e:
|
|
143
|
+
# We might see a connection dropout exception here during reboot, which is expected
|
|
144
|
+
logger.debug(f"Expected connection error during reboot: {e}")
|
|
145
|
+
except KeyboardInterrupt:
|
|
146
|
+
# Allow keyboard interrupts to propagate
|
|
147
|
+
raise
|
|
148
|
+
except SystemExit:
|
|
149
|
+
# Allow system exits to propagate
|
|
150
|
+
raise
|
|
151
|
+
except Exception as e:
|
|
152
|
+
# Log unexpected exceptions but continue with reboot verification
|
|
153
|
+
logger.warning(f"Unexpected exception during reboot command: {e}")
|
|
154
|
+
|
|
155
|
+
# Since we are NOT a session, we need to verify the remote machine is back up by attempting to
|
|
156
|
+
# connect to the machine and run a command. We raise a TimeoutError if we cannot connect and
|
|
157
|
+
# run a command before the reboot_timeout runs out.
|
|
158
|
+
|
|
159
|
+
reboot_timeout = 300 # 5 minutes default timeout
|
|
160
|
+
if aspects and hasattr(aspects, 'completion_timeout'):
|
|
161
|
+
reboot_timeout = aspects.completion_timeout
|
|
162
|
+
|
|
163
|
+
start_time = time.time()
|
|
164
|
+
end_time = start_time + reboot_timeout
|
|
165
|
+
retry_interval = 10 # Wait 10 seconds between attempts
|
|
166
|
+
|
|
167
|
+
# Wait a bit for the reboot to start
|
|
168
|
+
time.sleep(30)
|
|
169
|
+
|
|
170
|
+
while time.time() < end_time:
|
|
171
|
+
try:
|
|
172
|
+
# Try to connect and run a simple command
|
|
173
|
+
if self.verify_connectivity():
|
|
174
|
+
logger.info(f"Host {self._host} is back online after reboot")
|
|
175
|
+
return
|
|
176
|
+
except Exception:
|
|
177
|
+
# Connection failed, continue waiting
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
time.sleep(retry_interval)
|
|
181
|
+
|
|
182
|
+
# If we get here, the timeout was reached
|
|
183
|
+
raise TimeoutError(f"Host {self._host} did not come back online within {reboot_timeout} seconds after reboot")
|
|
184
|
+
|
|
185
|
+
def run_cmd(self, command: str, exp_status: Union[int, Sequence]=0, user: str = None, pty_params: dict = None, aspects: Optional[AspectsCmd] = None, ssh_client: Optional[SshClient]=None) -> Tuple[int, str, str]: # pylint: disable=arguments-differ
|
|
186
|
+
"""
|
|
187
|
+
Runs a command on the designated host using the specified parameters.
|
|
188
|
+
|
|
189
|
+
:param command: The command to run.
|
|
190
|
+
:param exp_status: An integer or sequence of integers that specify the set of expected status codes from the command.
|
|
191
|
+
:param user: The registered name of the user role to use to lookup the credentials for running the command.
|
|
192
|
+
:param pty_params: The pty parameters to use to request a PTY when running the command.
|
|
193
|
+
:param aspects: The run aspects to use when running the command.
|
|
194
|
+
:param ssh_client: An optional connected SSHClient that should be for the operation.
|
|
195
|
+
|
|
196
|
+
:returns: The status, stderr and stdout from the command that was run.
|
|
197
|
+
"""
|
|
198
|
+
cleanup_client = False
|
|
199
|
+
|
|
200
|
+
status, stdout, stderr = None, None, None
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
if ssh_client is None:
|
|
204
|
+
# If we are not passed in a client to use, we create one
|
|
205
|
+
# and then clean it up before returning
|
|
206
|
+
ssh_client = self._create_client(user)
|
|
207
|
+
cleanup_client = True
|
|
208
|
+
|
|
209
|
+
status, stdout, stderr = self._run_cmd(ssh_client, command, exp_status=exp_status, user=user, pty_params=pty_params, aspects=aspects)
|
|
210
|
+
|
|
211
|
+
finally:
|
|
212
|
+
# If we created a client, clean it up
|
|
213
|
+
if cleanup_client:
|
|
214
|
+
ssh_client.close()
|
|
215
|
+
del ssh_client
|
|
216
|
+
|
|
217
|
+
return status, stdout, stderr
|
|
218
|
+
|
|
219
|
+
def directory_tree(self, root_dir: str, depth: int = 1, ssh_client: Optional[SshClient] = None) -> dict:
|
|
220
|
+
"""
|
|
221
|
+
Method that creates a directory tree for the folder.
|
|
222
|
+
|
|
223
|
+
:param root_dir: The root directory to scan when creating the tree.
|
|
224
|
+
:param depth: The depth to scan to
|
|
225
|
+
:param ssh_client: An optional connected SSHClient that should be for the operation.
|
|
226
|
+
|
|
227
|
+
:returns: A dictionary with a tree of information about the directory tree found on the remote system.
|
|
228
|
+
"""
|
|
229
|
+
dir_info = {}
|
|
230
|
+
|
|
231
|
+
cleanup_client = False
|
|
232
|
+
try:
|
|
233
|
+
if ssh_client is None:
|
|
234
|
+
ssh_client = self._create_client()
|
|
235
|
+
cleanup_client = True
|
|
236
|
+
|
|
237
|
+
dir_info = self._directory_tree(ssh_client, root_dir, depth=depth)
|
|
238
|
+
|
|
239
|
+
finally:
|
|
240
|
+
if cleanup_client:
|
|
241
|
+
ssh_client.close()
|
|
242
|
+
del ssh_client
|
|
243
|
+
|
|
244
|
+
return dir_info
|
|
245
|
+
|
|
246
|
+
def directory(self, root_dir: str, ssh_client: Optional[SshClient] = None) -> dict:
|
|
247
|
+
"""
|
|
248
|
+
Method that creates a directory listing for the folder.
|
|
249
|
+
|
|
250
|
+
:param root_dir: The directory to scan when creating the tree.
|
|
251
|
+
:param ssh_client: An optional connected SSHClient that should be for the operation.
|
|
252
|
+
|
|
253
|
+
:returns: A dictionary that contains information about the items in the target directory.
|
|
254
|
+
"""
|
|
255
|
+
dir_info = {}
|
|
256
|
+
|
|
257
|
+
cleanup_client = False
|
|
258
|
+
try:
|
|
259
|
+
if ssh_client is None:
|
|
260
|
+
ssh_client = self._create_client()
|
|
261
|
+
cleanup_client = True
|
|
262
|
+
|
|
263
|
+
dir_info = self._directory(ssh_client, root_dir)
|
|
264
|
+
|
|
265
|
+
finally:
|
|
266
|
+
if cleanup_client:
|
|
267
|
+
ssh_client.close()
|
|
268
|
+
del ssh_client
|
|
269
|
+
|
|
270
|
+
return dir_info
|
|
271
|
+
|
|
272
|
+
def directory_exists(self, remotedir: str, ssh_client: Optional[SshClient] = None) -> bool:
|
|
273
|
+
"""
|
|
274
|
+
Method used to check if a remote directory exists.
|
|
275
|
+
|
|
276
|
+
:param remotedir: The remote remotedir path to check for existence.
|
|
277
|
+
:param ssh_client: An optional connected SSHClient that should be for the operation.
|
|
278
|
+
|
|
279
|
+
:returns: ``True`` if the directory exists, otherwise ``False``.
|
|
280
|
+
"""
|
|
281
|
+
exists = False
|
|
282
|
+
cleanup_client = False
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
if ssh_client is None:
|
|
286
|
+
ssh_client = self._create_client()
|
|
287
|
+
cleanup_client = True
|
|
288
|
+
|
|
289
|
+
exists = self._directory_exists(ssh_client, remotedir)
|
|
290
|
+
|
|
291
|
+
finally:
|
|
292
|
+
if cleanup_client:
|
|
293
|
+
ssh_client.close()
|
|
294
|
+
del ssh_client
|
|
295
|
+
|
|
296
|
+
return exists
|
|
297
|
+
|
|
298
|
+
def file_exists(self, remotepath: str, ssh_client: Optional[SshClient] = None) -> bool:
|
|
299
|
+
"""
|
|
300
|
+
Method used to check if a remote file exists.
|
|
301
|
+
|
|
302
|
+
:param remotepath: The remote file path to check for existence.
|
|
303
|
+
:param ssh_client: An optional connected SSHClient that should be for the operation.
|
|
304
|
+
|
|
305
|
+
:returns: ``True`` if the file exists, otherwise ``False``.
|
|
306
|
+
"""
|
|
307
|
+
exists = False
|
|
308
|
+
cleanup_client = False
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
if ssh_client is None:
|
|
312
|
+
ssh_client = self._create_client()
|
|
313
|
+
cleanup_client = True
|
|
314
|
+
|
|
315
|
+
exists = self._file_exists(ssh_client, remotepath)
|
|
316
|
+
|
|
317
|
+
finally:
|
|
318
|
+
if cleanup_client:
|
|
319
|
+
ssh_client.close()
|
|
320
|
+
del ssh_client
|
|
321
|
+
|
|
322
|
+
return exists
|
|
323
|
+
|
|
324
|
+
def file_pull(self, remotepath: str, localpath: str, ssh_client: Optional[SshClient] = None):
|
|
325
|
+
"""
|
|
326
|
+
Method used to pull a remote file to a local file path.
|
|
327
|
+
|
|
328
|
+
:param remotepath: The remote file path to pull to the local file.
|
|
329
|
+
:param localpath: The local file path to pull the content to.
|
|
330
|
+
:param ssh_client: An optional connected SSHClient that should be for the operation.
|
|
331
|
+
"""
|
|
332
|
+
cleanup_client = False
|
|
333
|
+
try:
|
|
334
|
+
if ssh_client is None:
|
|
335
|
+
ssh_client = self._create_client()
|
|
336
|
+
cleanup_client = True
|
|
337
|
+
|
|
338
|
+
self._file_pull(ssh_client, remotepath, localpath)
|
|
339
|
+
|
|
340
|
+
finally:
|
|
341
|
+
if cleanup_client:
|
|
342
|
+
ssh_client.close()
|
|
343
|
+
del ssh_client
|
|
344
|
+
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
def file_push(self, localpath: str, remotepath: str, ssh_client: Optional[SshClient] = None):
|
|
348
|
+
"""
|
|
349
|
+
Method used to push a local file to a remote file path.
|
|
350
|
+
|
|
351
|
+
:param localpath: The local file path to push the content of to the remote file.
|
|
352
|
+
:param remotepath: The remote file path to push content to.
|
|
353
|
+
:param ssh_client: An optional connected SSHClient that should be for the operation.
|
|
354
|
+
"""
|
|
355
|
+
cleanup_client = False
|
|
356
|
+
try:
|
|
357
|
+
if ssh_client is None:
|
|
358
|
+
ssh_client = self._create_client()
|
|
359
|
+
cleanup_client = True
|
|
360
|
+
|
|
361
|
+
self._file_push(ssh_client, localpath, remotepath)
|
|
362
|
+
|
|
363
|
+
finally:
|
|
364
|
+
if cleanup_client:
|
|
365
|
+
ssh_client.close()
|
|
366
|
+
del ssh_client
|
|
367
|
+
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
def get_home_directory(self, aspects: Optional[AspectsCmd] = None) -> str:
|
|
371
|
+
"""
|
|
372
|
+
Method used to get the home directory of the credentialed user from a remote machine.
|
|
373
|
+
"""
|
|
374
|
+
home_dir = self._get_home_directory(aspects=aspects)
|
|
375
|
+
return home_dir
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SSH Authentication Layer
|
|
3
|
+
"""
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
from ccai.protocols.ssh import sshconstants
|
|
7
|
+
from ccai.protocols.ssh.sshpacket import SshPacket
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
class SshAuthError(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
class SshAuthAgent:
|
|
15
|
+
"""
|
|
16
|
+
Handles Client-side User Authentication.
|
|
17
|
+
"""
|
|
18
|
+
def __init__(self, transport):
|
|
19
|
+
self.transport = transport
|
|
20
|
+
self.username = None
|
|
21
|
+
self.service = "ssh-connection"
|
|
22
|
+
self.auth_methods = []
|
|
23
|
+
self.authenticated = False
|
|
24
|
+
self.event = threading.Event()
|
|
25
|
+
self.service_accepted = threading.Event()
|
|
26
|
+
self.last_exception = None
|
|
27
|
+
|
|
28
|
+
def request_userauth_service(self):
|
|
29
|
+
"""
|
|
30
|
+
Request the 'ssh-userauth' service and block until the server accepts it.
|
|
31
|
+
|
|
32
|
+
RFC 4252 requires the client to send SSH_MSG_SERVICE_REQUEST for
|
|
33
|
+
'ssh-userauth' and receive SSH_MSG_SERVICE_ACCEPT before any
|
|
34
|
+
SSH_MSG_USERAUTH_REQUEST. This is sent once per connection.
|
|
35
|
+
"""
|
|
36
|
+
if self.service_accepted.is_set():
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
pkt = SshPacket(sshconstants.SSH_MSG_SERVICE_REQUEST)
|
|
40
|
+
pkt.add_string("ssh-userauth")
|
|
41
|
+
self.transport.send_packet(pkt)
|
|
42
|
+
|
|
43
|
+
accepted = self.service_accepted.wait(timeout=30)
|
|
44
|
+
if not accepted:
|
|
45
|
+
raise SshAuthError("Timed out waiting for SERVICE_ACCEPT (ssh-userauth)")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
def authenticate_none(self, username: str):
|
|
49
|
+
"""
|
|
50
|
+
Attempt 'none' authentication to get list of allowed methods.
|
|
51
|
+
"""
|
|
52
|
+
self.username = username
|
|
53
|
+
self.request_userauth_service()
|
|
54
|
+
self._send_auth_request("none")
|
|
55
|
+
self._wait_for_response()
|
|
56
|
+
return self.auth_methods
|
|
57
|
+
|
|
58
|
+
def authenticate_password(self, username: str, password: str):
|
|
59
|
+
"""
|
|
60
|
+
Attempt password authentication.
|
|
61
|
+
"""
|
|
62
|
+
self.username = username
|
|
63
|
+
self.request_userauth_service()
|
|
64
|
+
self._send_auth_request("password", password=password)
|
|
65
|
+
self._wait_for_response()
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
def _send_auth_request(self, method: str, password: str = None):
|
|
69
|
+
pkt = SshPacket(sshconstants.SSH_MSG_USERAUTH_REQUEST)
|
|
70
|
+
pkt.add_string(self.username)
|
|
71
|
+
pkt.add_string(self.service)
|
|
72
|
+
pkt.add_string(method)
|
|
73
|
+
|
|
74
|
+
if method == "password":
|
|
75
|
+
pkt.add_boolean(False) # Change password?
|
|
76
|
+
pkt.add_string(password)
|
|
77
|
+
|
|
78
|
+
self.event.clear()
|
|
79
|
+
self.transport.send_packet(pkt)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
def dispatch_message(self, packet: SshPacket):
|
|
83
|
+
"""
|
|
84
|
+
Called by the transport when an auth-related packet is received.
|
|
85
|
+
"""
|
|
86
|
+
if not packet.payload:
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
msg_id = packet.get_byte()
|
|
90
|
+
if msg_id == sshconstants.SSH_MSG_SERVICE_ACCEPT:
|
|
91
|
+
self.service_accepted.set()
|
|
92
|
+
elif msg_id == sshconstants.SSH_MSG_USERAUTH_SUCCESS:
|
|
93
|
+
self.authenticated = True
|
|
94
|
+
self.event.set()
|
|
95
|
+
elif msg_id == sshconstants.SSH_MSG_USERAUTH_FAILURE:
|
|
96
|
+
# Parse allowed methods
|
|
97
|
+
methods = packet.get_name_list()
|
|
98
|
+
packet.get_boolean() # 'partial success' flag, read to consume the field; it is not used
|
|
99
|
+
self.auth_methods = methods
|
|
100
|
+
self.authenticated = False
|
|
101
|
+
self.last_exception = SshAuthError(f"Authentication failed. Allowed: {methods}")
|
|
102
|
+
self.event.set()
|
|
103
|
+
elif msg_id == sshconstants.SSH_MSG_USERAUTH_BANNER:
|
|
104
|
+
banner = packet.get_string().decode('utf-8')
|
|
105
|
+
logger.info(f"Auth Banner: {banner}")
|
|
106
|
+
else:
|
|
107
|
+
logger.warning(f"Unknown auth packet: {msg_id}")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
def _wait_for_response(self):
|
|
111
|
+
self.event.wait(timeout=30)
|
|
112
|
+
if not self.event.is_set():
|
|
113
|
+
raise TimeoutError("Auth request timed out")
|
|
114
|
+
if not self.authenticated:
|
|
115
|
+
if self.last_exception:
|
|
116
|
+
raise self.last_exception
|
|
117
|
+
raise SshAuthError("Authentication failed")
|
|
118
|
+
return
|