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
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
"""
|
|
2
|
+
.. module:: sshbase
|
|
3
|
+
:platform: Darwin, Linux, Unix, Windows
|
|
4
|
+
:synopsis: Module containing the :class:`SshBase` 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
|
+
import os
|
|
19
|
+
import socket
|
|
20
|
+
import stat
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
from datetime import datetime, timedelta
|
|
25
|
+
from io import StringIO
|
|
26
|
+
|
|
27
|
+
from ccai.protocols.ssh.sshclient import SshClient
|
|
28
|
+
from ccai.protocols.ssh.sshkeys import SshRSAKey, SshEd25519Key
|
|
29
|
+
from ccai.protocols.ssh.sshconnection import SshChannel
|
|
30
|
+
|
|
31
|
+
from ccai.waiting.waitmodel import TimeoutContext
|
|
32
|
+
|
|
33
|
+
from ccai.errors.exceptions import ConfigurationError, AbstractMethodError, SemanticError
|
|
34
|
+
|
|
35
|
+
from ccai.interop.aspects import ActionPattern, AspectsCmd, LoggingPattern, DEFAULT_CMD_ASPECTS
|
|
36
|
+
from ccai.credentials.sshcredential import SshCredential
|
|
37
|
+
from ccai.interop.interfaces.isystemcontext import ISystemContext
|
|
38
|
+
from ccai.xmodules.xformatting import indent_lines
|
|
39
|
+
from ccai.xmodules.xlogging.monitoredscope import MonitoredScope
|
|
40
|
+
|
|
41
|
+
from ccai.protocols.ssh.sshconst import (
|
|
42
|
+
SshJumpParams,
|
|
43
|
+
DEFAULT_SSH_TIMEOUT,
|
|
44
|
+
DEFAULT_SSH_RETRY_INTERVAL,
|
|
45
|
+
TEMPLATE_COMMAND_FAILURE,
|
|
46
|
+
TEMPLATE_COMMAND_SUCCESS
|
|
47
|
+
)
|
|
48
|
+
from ccai.protocols.ssh.sshhelpers import (
|
|
49
|
+
primitive_directory_exists,
|
|
50
|
+
primitive_file_exists,
|
|
51
|
+
primitive_file_pull,
|
|
52
|
+
primitive_file_push,
|
|
53
|
+
primitive_list_directory,
|
|
54
|
+
primitive_list_tree,
|
|
55
|
+
sftp_list_directory,
|
|
56
|
+
sftp_list_tree,
|
|
57
|
+
ssh_execute_command
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
logger = getEnhancedLogger()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SshBase(ISystemContext):
|
|
64
|
+
"""
|
|
65
|
+
The :class:`SshBase` object provides for the sharing of state and fuctional patterns
|
|
66
|
+
for APIs between the :class:`SshSession` object and the :class:`SshAgent`.
|
|
67
|
+
"""
|
|
68
|
+
def __init__(self, host: str, primary_credential: SshCredential, users: Optional[Dict[str, SshCredential]] = None,
|
|
69
|
+
port: int = 22, jump: Union[str, SshJumpParams, None] = None, pty_params: Optional[dict] = None,
|
|
70
|
+
called_id: Optional[str]=None, look_for_keys: bool = False,
|
|
71
|
+
aspects: AspectsCmd = DEFAULT_CMD_ASPECTS):
|
|
72
|
+
"""
|
|
73
|
+
Initialize the base SSH helper class.
|
|
74
|
+
|
|
75
|
+
:param host: Target host to connect to.
|
|
76
|
+
:param primary_credential: Primary credential for authentication.
|
|
77
|
+
:param users: Optional dictionary of additional users.
|
|
78
|
+
:param port: SSH port number.
|
|
79
|
+
:param jump: Optional jump host specification.
|
|
80
|
+
:param pty_params: Optional PTY parameters for interactive sessions.
|
|
81
|
+
:param called_id: Identifier used in logs.
|
|
82
|
+
:param look_for_keys: If ``True`` search for SSH keys automatically.
|
|
83
|
+
:param aspects: Default command aspects for operations.
|
|
84
|
+
"""
|
|
85
|
+
self._host = host
|
|
86
|
+
|
|
87
|
+
self._primary_credential = primary_credential
|
|
88
|
+
self._username = primary_credential.username
|
|
89
|
+
self._password = primary_credential.password
|
|
90
|
+
self._keyfile = None
|
|
91
|
+
if hasattr(primary_credential, "keyfile"):
|
|
92
|
+
self._keyfile = primary_credential.keyfile
|
|
93
|
+
self._keyraw = None
|
|
94
|
+
if hasattr(primary_credential, "keyraw"):
|
|
95
|
+
self._keyraw = primary_credential.keyraw
|
|
96
|
+
self._keypasswd = None
|
|
97
|
+
if hasattr(primary_credential, "keypasswd"):
|
|
98
|
+
self._keypasswd = primary_credential.keypasswd
|
|
99
|
+
self._allow_agent = None
|
|
100
|
+
if hasattr(primary_credential, "allow_agent"):
|
|
101
|
+
self._allow_agent = primary_credential.allow_agent
|
|
102
|
+
self._primitive = None
|
|
103
|
+
if hasattr(primary_credential, "primitive"):
|
|
104
|
+
self._primitive = primary_credential.primitive
|
|
105
|
+
|
|
106
|
+
self._users = users
|
|
107
|
+
self._port = port
|
|
108
|
+
self._jump = jump
|
|
109
|
+
self._pty_params = pty_params
|
|
110
|
+
self._called_id = called_id
|
|
111
|
+
|
|
112
|
+
self._target_tag = host
|
|
113
|
+
if self._called_id is not None:
|
|
114
|
+
self._target_tag = "[{}]".format(called_id)
|
|
115
|
+
|
|
116
|
+
self._look_for_keys = look_for_keys
|
|
117
|
+
self._aspects = aspects
|
|
118
|
+
|
|
119
|
+
self._ipaddr = socket.gethostbyname(self._host)
|
|
120
|
+
|
|
121
|
+
self._user_lookup_table = {}
|
|
122
|
+
self._group_lookup_table = {}
|
|
123
|
+
|
|
124
|
+
if self._password is None and self._keyfile is None and self._keyraw is None and self._allow_agent is not True:
|
|
125
|
+
raise ConfigurationError("SshAgent requires either a 'password', an identity 'keyfile', 'keyraw' or allow_agent=True be specified.") from None
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def aspects(self) -> AspectsCmd:
|
|
130
|
+
"""
|
|
131
|
+
The logging, iteration and other aspects that have been assigned to be used with interacting with the remote SSH service.
|
|
132
|
+
"""
|
|
133
|
+
return self._aspects
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def host(self) -> str:
|
|
137
|
+
"""
|
|
138
|
+
The target SSH host machine.
|
|
139
|
+
"""
|
|
140
|
+
return self._host
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def ipaddr(self) -> str:
|
|
144
|
+
"""
|
|
145
|
+
The IP address that was found to be associated with the specified host machine.
|
|
146
|
+
"""
|
|
147
|
+
return self._ipaddr
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def jump(self):
|
|
151
|
+
"""
|
|
152
|
+
A 'ProxyCommand' jump command that can be used for Advanced Server Access.
|
|
153
|
+
"""
|
|
154
|
+
return self._jump
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def look_for_keys(self):
|
|
158
|
+
"""
|
|
159
|
+
A value indicating if paramiko should search for keys.
|
|
160
|
+
"""
|
|
161
|
+
return self._look_for_keys
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def port(self) -> int:
|
|
165
|
+
"""
|
|
166
|
+
The port to use for SSH communications. The default is 22.
|
|
167
|
+
"""
|
|
168
|
+
return self._port
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def primary_credential(self):
|
|
172
|
+
"""
|
|
173
|
+
Returns the 'primary' user credential provided for this ssh client object.
|
|
174
|
+
"""
|
|
175
|
+
return self._primary_credential
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def pty_params(self) -> dict:
|
|
179
|
+
"""
|
|
180
|
+
A dictionary of parameters that are to be passed when getting a PTY when interacting with the remote SSH server.
|
|
181
|
+
"""
|
|
182
|
+
return self._pty_params
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def users(self) -> Dict[str, SshCredential]:
|
|
186
|
+
"""
|
|
187
|
+
A dictionary of roles and user credentials that can be used for interaction with the remote SSH server.
|
|
188
|
+
"""
|
|
189
|
+
return self._users
|
|
190
|
+
|
|
191
|
+
def lookup_user_by_uid(self, uid: int, update: bool = True) -> Union[str, None]:
|
|
192
|
+
"""
|
|
193
|
+
A helper method used to lookup cached user information by uid.
|
|
194
|
+
|
|
195
|
+
:param uid: The uid of a user.
|
|
196
|
+
:param update: A boolean indicating that the table should be updated if the uid is not found.
|
|
197
|
+
|
|
198
|
+
:returns: The username associated with ``uid`` or ``None`` if not found.
|
|
199
|
+
"""
|
|
200
|
+
username = None
|
|
201
|
+
|
|
202
|
+
if uid in self._user_lookup_table:
|
|
203
|
+
username = self._user_lookup_table[uid]
|
|
204
|
+
elif update:
|
|
205
|
+
status, stdout, _ = self.run_cmd("id -n -u %d" % uid)
|
|
206
|
+
if status == 0:
|
|
207
|
+
username = stdout.strip()
|
|
208
|
+
self._user_lookup_table[uid] = username
|
|
209
|
+
|
|
210
|
+
return username
|
|
211
|
+
|
|
212
|
+
def lookup_group_by_gid(self, gid: int, update: bool = True) -> Union[str, None]:
|
|
213
|
+
"""
|
|
214
|
+
A helper method used to lookup cached group information by gid.
|
|
215
|
+
|
|
216
|
+
:param gid: The gid of a group.
|
|
217
|
+
:param update: A boolean indicating that the table should be updated if the gid is not found.
|
|
218
|
+
|
|
219
|
+
:returns: The name of the group associated with the group id or None.
|
|
220
|
+
"""
|
|
221
|
+
grpname = None
|
|
222
|
+
|
|
223
|
+
if gid in self._group_lookup_table:
|
|
224
|
+
grpname = self._group_lookup_table[gid]
|
|
225
|
+
elif update:
|
|
226
|
+
status, stdout, _ = self.run_cmd("id -n -g %d" % gid)
|
|
227
|
+
if status == 0:
|
|
228
|
+
grpname = stdout.strip()
|
|
229
|
+
self._group_lookup_table[gid] = grpname
|
|
230
|
+
|
|
231
|
+
return grpname
|
|
232
|
+
|
|
233
|
+
def reboot(self, aspects: Optional[AspectsCmd] = None):
|
|
234
|
+
"""
|
|
235
|
+
Reboots the designated host by running the 'reboot' command on the host.
|
|
236
|
+
|
|
237
|
+
..note: This method is not a typical SSH functionality, but it was added so that
|
|
238
|
+
automatic reconnects can be implemented as required on session objects.
|
|
239
|
+
|
|
240
|
+
"""
|
|
241
|
+
raise AbstractMethodError("SshBase.reboot must be overloaded by derived class '%s'." % type(self).__name__) from None
|
|
242
|
+
|
|
243
|
+
def run_cmd(self, command: str, exp_status: Union[int, Sequence]=0, user: str = None, pty_params: dict = None, aspects: Optional[AspectsCmd] = None) -> Tuple[int, str, str]:
|
|
244
|
+
"""
|
|
245
|
+
Runs a command on the designated host using the specified parameters.
|
|
246
|
+
|
|
247
|
+
:param command: The command to run.
|
|
248
|
+
:param exp_status: An integer or sequence of integers that specify the set of expected status codes from the command.
|
|
249
|
+
:param user: The registered name of the user role to use to lookup the credentials for running the command.
|
|
250
|
+
:param pty_params: The pty parameters to use to request a PTY when running the command.
|
|
251
|
+
:param aspects: The run aspects to use when running the command.
|
|
252
|
+
|
|
253
|
+
:returns: The status, stderr and stdout from the command that was run.
|
|
254
|
+
"""
|
|
255
|
+
raise AbstractMethodError("SshBase.run_cmd must be overloaded by derived class '%s'." % type(self).__name__) from None
|
|
256
|
+
|
|
257
|
+
def verify_connectivity(self) -> bool:
|
|
258
|
+
"""
|
|
259
|
+
Method that can be used to verify connectivity to the target computer.
|
|
260
|
+
|
|
261
|
+
:returns: A boolean value indicating whether connectivity with the remote machine was successful.
|
|
262
|
+
"""
|
|
263
|
+
vok = False
|
|
264
|
+
|
|
265
|
+
hello_cmd = "echo Hello"
|
|
266
|
+
status, stdout, _ = self.run_cmd(hello_cmd)
|
|
267
|
+
if status == 0 and stdout.strip() == "Hello":
|
|
268
|
+
vok = True
|
|
269
|
+
|
|
270
|
+
return vok
|
|
271
|
+
|
|
272
|
+
def _create_client(self, session_user: Optional[str] = None) -> SshClient:
|
|
273
|
+
"""
|
|
274
|
+
Create an SSHClient to use for running commands and performing FTP operations.
|
|
275
|
+
|
|
276
|
+
:param session_user: The user role and associated credentials to use when creating the SSHClient.
|
|
277
|
+
|
|
278
|
+
:returns: An SSHClient object connected to the remote machine under the default or specified user credential.
|
|
279
|
+
"""
|
|
280
|
+
cl_username = self._username
|
|
281
|
+
cl_password = self._password
|
|
282
|
+
cl_keyfile = self._keyfile
|
|
283
|
+
cl_keyraw = self._keyraw
|
|
284
|
+
cl_keypasswd = self._keypasswd
|
|
285
|
+
cl_allow_agent = self._allow_agent
|
|
286
|
+
|
|
287
|
+
now_time = datetime.now()
|
|
288
|
+
begin_time = now_time
|
|
289
|
+
end_time = begin_time + timedelta(seconds=self._aspects.connection_timeout)
|
|
290
|
+
|
|
291
|
+
connection_interval = self._aspects.connection_interval
|
|
292
|
+
allowed_connection_exceptions = self._aspects.allowed_connection_exceptions
|
|
293
|
+
|
|
294
|
+
ssh_client = None
|
|
295
|
+
while now_time < end_time:
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
|
|
299
|
+
if session_user is not None:
|
|
300
|
+
if session_user not in self._users:
|
|
301
|
+
errmsg_list = [
|
|
302
|
+
"No credentials found for the specified user '%s'." % session_user
|
|
303
|
+
]
|
|
304
|
+
errmsg = os.linesep.join(errmsg_list)
|
|
305
|
+
raise ConfigurationError(errmsg) from None
|
|
306
|
+
|
|
307
|
+
user_creds = self._users[session_user]
|
|
308
|
+
|
|
309
|
+
cl_username = user_creds.username
|
|
310
|
+
cl_password = user_creds.password
|
|
311
|
+
cl_keyfile = user_creds.keyfile
|
|
312
|
+
cl_keyraw = user_creds.keyraw
|
|
313
|
+
cl_keypasswd = user_creds.keypasswd
|
|
314
|
+
cl_allow_agent = user_creds.allow_agent
|
|
315
|
+
|
|
316
|
+
# Validate that required credentials are present
|
|
317
|
+
if cl_username is None:
|
|
318
|
+
raise ConfigurationError(f"Username is required for session user '{session_user}' but was not provided in user credentials.") from None
|
|
319
|
+
|
|
320
|
+
# Ensure at least one authentication method is available
|
|
321
|
+
if cl_password is None and cl_keyfile is None and cl_keyraw is None and not cl_allow_agent:
|
|
322
|
+
raise ConfigurationError(f"Session user '{session_user}' requires either a 'password', an identity 'keyfile', 'keyraw' or allow_agent=True be specified.") from None
|
|
323
|
+
|
|
324
|
+
pkey = None
|
|
325
|
+
if cl_keyfile is not None:
|
|
326
|
+
pkey = self._load_key_file(cl_keyfile, cl_keypasswd)
|
|
327
|
+
elif cl_keyraw is not None:
|
|
328
|
+
pkey = self._load_key(cl_keyraw, cl_keypasswd)
|
|
329
|
+
|
|
330
|
+
ssh_client = SshClient()
|
|
331
|
+
# ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # TODO: Implement policy
|
|
332
|
+
|
|
333
|
+
if self._jump is not None:
|
|
334
|
+
# proxy = paramiko.ProxyCommand(self._jump)
|
|
335
|
+
logger.warning("Jump host (ProxyCommand) not yet supported in SshClient. Ignoring jump host.")
|
|
336
|
+
# ssh_client.connect(self._ipaddr, port=self._port, username=cl_username, password=cl_password,
|
|
337
|
+
# pkey=pkey, allow_agent=cl_allow_agent, sock = proxy, look_for_keys=self._look_for_keys)
|
|
338
|
+
ssh_client.connect(self._ipaddr, port=self._port, username=cl_username, password=cl_password,
|
|
339
|
+
pkey=pkey, allow_agent=cl_allow_agent, look_for_keys=self._look_for_keys)
|
|
340
|
+
else:
|
|
341
|
+
ssh_client.connect(self._ipaddr, port=self._port, username=cl_username, password=cl_password,
|
|
342
|
+
pkey=pkey, allow_agent=cl_allow_agent, look_for_keys=self._look_for_keys)
|
|
343
|
+
|
|
344
|
+
break
|
|
345
|
+
|
|
346
|
+
except BaseException as cerr:
|
|
347
|
+
# Clean up the failed ssh_client before handling the exception
|
|
348
|
+
if ssh_client is not None:
|
|
349
|
+
try:
|
|
350
|
+
ssh_client.close()
|
|
351
|
+
except Exception:
|
|
352
|
+
pass # Ignore errors when closing failed client
|
|
353
|
+
ssh_client = None
|
|
354
|
+
|
|
355
|
+
extype = type(cerr)
|
|
356
|
+
|
|
357
|
+
if len(allowed_connection_exceptions) > 0 and extype in allowed_connection_exceptions:
|
|
358
|
+
errmsg = f"SSH connection attempt failed for host={self._ipaddr} begin={begin_time} end={end_time} now={now_time}."
|
|
359
|
+
logger.error(errmsg)
|
|
360
|
+
|
|
361
|
+
now_time = datetime.now()
|
|
362
|
+
if now_time > end_time:
|
|
363
|
+
raise ConnectionError(errmsg) from cerr
|
|
364
|
+
else:
|
|
365
|
+
time.sleep(connection_interval)
|
|
366
|
+
else:
|
|
367
|
+
raise # If the exception we got is not an allowed type, re-raise it
|
|
368
|
+
|
|
369
|
+
# Ensure we have a valid connection before returning
|
|
370
|
+
if ssh_client is None:
|
|
371
|
+
errmsg = f"Failed to establish SSH connection to {self._ipaddr} within {self._aspects.connection_timeout} seconds."
|
|
372
|
+
raise ConnectionError(errmsg)
|
|
373
|
+
|
|
374
|
+
return ssh_client
|
|
375
|
+
|
|
376
|
+
def _directory(self, ssh_client: SshClient, root_dir: str) -> dict:
|
|
377
|
+
"""
|
|
378
|
+
Private method that creates a directory listing for the folder.
|
|
379
|
+
|
|
380
|
+
:param ssh_client: The SSHClient to use to for geting a list tree.
|
|
381
|
+
:param root_dir: The directory to scan when creating the tree.
|
|
382
|
+
|
|
383
|
+
:returns: A dictionary that contains information about the items in the target directory.
|
|
384
|
+
"""
|
|
385
|
+
dir_info = {}
|
|
386
|
+
|
|
387
|
+
if not self._primitive:
|
|
388
|
+
# transport = ssh_client.get_transport()
|
|
389
|
+
|
|
390
|
+
sftp = ssh_client.open_sftp()
|
|
391
|
+
try:
|
|
392
|
+
dir_info = sftp_list_directory(sftp, root_dir, self.lookup_user_by_uid, self.lookup_group_by_gid)
|
|
393
|
+
finally:
|
|
394
|
+
sftp.close()
|
|
395
|
+
|
|
396
|
+
else:
|
|
397
|
+
dir_info = primitive_list_directory(ssh_client, root_dir)
|
|
398
|
+
|
|
399
|
+
return dir_info
|
|
400
|
+
|
|
401
|
+
def _directory_exists(self, ssh_client: SshClient, remotedir: str) -> bool:
|
|
402
|
+
"""
|
|
403
|
+
Private helper method used to check if the remote directory exists.
|
|
404
|
+
"""
|
|
405
|
+
exists = False
|
|
406
|
+
|
|
407
|
+
if not self._primitive:
|
|
408
|
+
# transport = ssh_client.get_transport()
|
|
409
|
+
|
|
410
|
+
sftp = ssh_client.open_sftp()
|
|
411
|
+
try:
|
|
412
|
+
fsresult = sftp.stat(remotedir)
|
|
413
|
+
if stat.S_ISDIR(fsresult.st_mode):
|
|
414
|
+
exists = True
|
|
415
|
+
except (FileNotFoundError, IOError):
|
|
416
|
+
exists = False
|
|
417
|
+
finally:
|
|
418
|
+
sftp.close()
|
|
419
|
+
|
|
420
|
+
else:
|
|
421
|
+
exists = primitive_directory_exists(ssh_client, remotedir)
|
|
422
|
+
|
|
423
|
+
return exists
|
|
424
|
+
|
|
425
|
+
def _directory_tree(self, ssh_client: SshClient, root_dir: str, depth: int = 1) -> dict:
|
|
426
|
+
"""
|
|
427
|
+
Private method that creates a directory tree for the folder.
|
|
428
|
+
|
|
429
|
+
:param ssh_client: The SSHClient to use to for geting a list tree.
|
|
430
|
+
:param root_dir: The root directory to scan when creating the tree.
|
|
431
|
+
:param depth: The depth to scan to
|
|
432
|
+
|
|
433
|
+
:returns: A dictionary with a tree of information about the directory tree found on the remote system.
|
|
434
|
+
"""
|
|
435
|
+
dir_info = {}
|
|
436
|
+
|
|
437
|
+
if not self._primitive:
|
|
438
|
+
# transport = ssh_client.get_transport()
|
|
439
|
+
|
|
440
|
+
sftp = ssh_client.open_sftp()
|
|
441
|
+
try:
|
|
442
|
+
dir_info = sftp_list_tree(sftp, root_dir, self.lookup_user_by_uid, self.lookup_group_by_gid, max_depth=depth)
|
|
443
|
+
finally:
|
|
444
|
+
sftp.close()
|
|
445
|
+
|
|
446
|
+
else:
|
|
447
|
+
dir_info = primitive_list_tree(ssh_client, root_dir, max_depth=depth)
|
|
448
|
+
|
|
449
|
+
return dir_info
|
|
450
|
+
|
|
451
|
+
def _get_home_directory(self, aspects: Optional[AspectsCmd] = None) -> str:
|
|
452
|
+
"""
|
|
453
|
+
Private method that handles the getting of the home directory for the credential account from a remote machine.
|
|
454
|
+
|
|
455
|
+
:param aspects: The run aspects to use when running the command.
|
|
456
|
+
|
|
457
|
+
:returns: The remote home directory
|
|
458
|
+
"""
|
|
459
|
+
home_dir = None
|
|
460
|
+
|
|
461
|
+
home_cmd = 'echo "$HOME"'
|
|
462
|
+
status, stdout, stderr = self.run_cmd(home_cmd, aspects=aspects)
|
|
463
|
+
if status == 0:
|
|
464
|
+
home_dir = stdout.strip()
|
|
465
|
+
|
|
466
|
+
return home_dir
|
|
467
|
+
|
|
468
|
+
def _log_command_result(self, command: str, status: int, stdout: str, stderr: str, exp_status: Union[int, Sequence[int]], logging_pattern: LoggingPattern):
|
|
469
|
+
"""
|
|
470
|
+
Private method that handles the logging of command results based on the expected status and logging pattern
|
|
471
|
+
specified.
|
|
472
|
+
|
|
473
|
+
:param command: The command that was run.
|
|
474
|
+
:param status: The status code returned from running the command.
|
|
475
|
+
:param stdout: The contents of the standard output from running the command.
|
|
476
|
+
:param stderr: The contents of the error output from running the command.
|
|
477
|
+
:param exp_status: The status code or possible status codes that was expected to be returned from running the command.
|
|
478
|
+
:param logging_pattern: The result logging pattern (LoggingPattern) to use when logging.
|
|
479
|
+
"""
|
|
480
|
+
# pylint: disable=no-self-use
|
|
481
|
+
|
|
482
|
+
tgt_tag = ""
|
|
483
|
+
if self._target_tag is not None:
|
|
484
|
+
tgt_tag = self._target_tag
|
|
485
|
+
|
|
486
|
+
if isinstance(exp_status, int):
|
|
487
|
+
if status == exp_status:
|
|
488
|
+
if logging_pattern == LoggingPattern.ALL_RESULTS or logging_pattern == LoggingPattern.SUCCESS_ONLY:
|
|
489
|
+
logger.info(TEMPLATE_COMMAND_SUCCESS % (tgt_tag, command, stdout, stderr) )
|
|
490
|
+
else:
|
|
491
|
+
if status in exp_status:
|
|
492
|
+
if logging_pattern == LoggingPattern.ALL_RESULTS or logging_pattern == LoggingPattern.SUCCESS_ONLY:
|
|
493
|
+
logger.info(TEMPLATE_COMMAND_SUCCESS % (tgt_tag, command, stdout, stderr))
|
|
494
|
+
else:
|
|
495
|
+
if logging_pattern == LoggingPattern.ALL_RESULTS or logging_pattern == LoggingPattern.FAILURE_ONLY:
|
|
496
|
+
logger.error(TEMPLATE_COMMAND_FAILURE % (tgt_tag, command, stdout, stderr))
|
|
497
|
+
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
def _run_cmd(self, ssh_runner: Union[SshClient, SshChannel], command: str, exp_status: Union[int, Sequence]=0, user: str = None, pty_params: dict = None, aspects: Optional[AspectsCmd] = None) -> Tuple[int, str, str]:
|
|
501
|
+
"""
|
|
502
|
+
Private method that handles the running commands in a generic way so the logic and run pattern code can be shared between agent and session objects.
|
|
503
|
+
|
|
504
|
+
:param ssh_runner: A :class:`paramiko.SSHClient` or :class:`paramiko.Channel` object that can be used to run a command on a remote machine.
|
|
505
|
+
:param command: The command that is to be run.
|
|
506
|
+
:param exp_status: The status code or a sequence of possible status codes that are expected to be returned from running the command.
|
|
507
|
+
:param user: The role name of the user account to utilize when looking up the credentials used to connect to the remote host.
|
|
508
|
+
:param pty_params: A dictionary of parameters that are passed to paramiko to get a pty when running commands.
|
|
509
|
+
:param aspects: The run aspects to use when running the command.
|
|
510
|
+
|
|
511
|
+
:returns: The status, stderr and stdout from the command that was run.
|
|
512
|
+
"""
|
|
513
|
+
# Go through the overrides and if they are not passed use the agent defaults.
|
|
514
|
+
if pty_params is None:
|
|
515
|
+
pty_params = self._pty_params
|
|
516
|
+
|
|
517
|
+
if aspects is None:
|
|
518
|
+
aspects = self._aspects
|
|
519
|
+
|
|
520
|
+
if user is not None:
|
|
521
|
+
if self._users is None:
|
|
522
|
+
errmsg = "In order to pass a 'user' parameter, you must create the SSHAgent with a 'users' parameter with a dictionary of user credentials."
|
|
523
|
+
raise ConfigurationError(errmsg) from None
|
|
524
|
+
if user not in self._users:
|
|
525
|
+
errmsg = "The specified 'user=%s' was not found in the users credentials provided to SSHAgent." % user
|
|
526
|
+
raise ConfigurationError(errmsg) from None
|
|
527
|
+
|
|
528
|
+
completion_toctx = TimeoutContext(aspects.completion_timeout, aspects.completion_interval)
|
|
529
|
+
|
|
530
|
+
inactivity_timeout = aspects.inactivity_timeout
|
|
531
|
+
inactivity_interval = aspects.inactivity_interval
|
|
532
|
+
monitor_delay = aspects.monitor_delay
|
|
533
|
+
logging_pattern = aspects.logging_pattern
|
|
534
|
+
|
|
535
|
+
status, stdout, stderr = None, None, None
|
|
536
|
+
|
|
537
|
+
this_thr = threading.current_thread()
|
|
538
|
+
monmsg= "Thread failed to exit monitored scope. thid=%s thname=%s cmd=%s" % (this_thr.ident, this_thr.name, command)
|
|
539
|
+
|
|
540
|
+
# Run the command using the SINGULAR pattern, we run it once and then return the result
|
|
541
|
+
if aspects.action_pattern == ActionPattern.SINGLE_CALL:
|
|
542
|
+
|
|
543
|
+
# Setup a monitored scope for the call to the remote device in case of timeout failure
|
|
544
|
+
with MonitoredScope("RUNCMD-SINGULAR", monmsg, completion_toctx, notify_delay=monitor_delay) as _:
|
|
545
|
+
|
|
546
|
+
status, stdout, stderr = self._ssh_execute_command(ssh_runner, command, pty_params=pty_params,
|
|
547
|
+
inactivity_timeout=inactivity_timeout, inactivity_interval=inactivity_interval)
|
|
548
|
+
|
|
549
|
+
self._log_command_result(command, status, stdout, stderr, exp_status, logging_pattern)
|
|
550
|
+
|
|
551
|
+
# DO_UNTIL_SUCCESS, run the command until we get a successful expected result or a completion timeout has occured
|
|
552
|
+
elif aspects.action_pattern == ActionPattern.DO_UNTIL_SUCCESS:
|
|
553
|
+
|
|
554
|
+
completion_toctx.mark_begin()
|
|
555
|
+
|
|
556
|
+
while True:
|
|
557
|
+
|
|
558
|
+
# Setup a monitored scope for the call to the remote device in case of timeout failure
|
|
559
|
+
with MonitoredScope("RUNCMD-DO_UNTIL_SUCCESS", monmsg, completion_toctx, notify_delay=monitor_delay) as _:
|
|
560
|
+
status, stdout, stderr = self._ssh_execute_command(ssh_runner, command, pty_params=pty_params,
|
|
561
|
+
inactivity_timeout=inactivity_timeout, inactivity_interval=inactivity_interval)
|
|
562
|
+
|
|
563
|
+
self._log_command_result(command, status, stdout, stderr, exp_status, logging_pattern)
|
|
564
|
+
|
|
565
|
+
if isinstance(exp_status, int):
|
|
566
|
+
if status == exp_status:
|
|
567
|
+
break
|
|
568
|
+
elif status in exp_status:
|
|
569
|
+
break
|
|
570
|
+
|
|
571
|
+
if completion_toctx.final_attempt:
|
|
572
|
+
what_for="command success"
|
|
573
|
+
details = [
|
|
574
|
+
"CMD: %s" % command,
|
|
575
|
+
"STDOUT:",
|
|
576
|
+
indent_lines(stdout, 1),
|
|
577
|
+
"STDERR:",
|
|
578
|
+
indent_lines(stderr, 1),
|
|
579
|
+
]
|
|
580
|
+
toerr = completion_toctx.create_timeout(what_for=what_for, detail=details)
|
|
581
|
+
raise toerr
|
|
582
|
+
|
|
583
|
+
elif not completion_toctx.should_continue():
|
|
584
|
+
completion_toctx.mark_final_attempt()
|
|
585
|
+
|
|
586
|
+
time.sleep(completion_toctx.interval)
|
|
587
|
+
|
|
588
|
+
# DO_WHILE_SUCCESS, run the command while it is succeeds or a completion timeout has occured
|
|
589
|
+
elif aspects.action_pattern == ActionPattern.DO_WHILE_SUCCESS:
|
|
590
|
+
|
|
591
|
+
completion_toctx.mark_begin()
|
|
592
|
+
|
|
593
|
+
while True:
|
|
594
|
+
|
|
595
|
+
with MonitoredScope("RUNCMD-DO_WHILE_SUCCESS", monmsg, completion_toctx, notify_delay=monitor_delay) as _:
|
|
596
|
+
status, stdout, stderr = self._ssh_execute_command(ssh_runner, command, pty_params=pty_params,
|
|
597
|
+
inactivity_timeout=inactivity_timeout, inactivity_interval=inactivity_interval)
|
|
598
|
+
|
|
599
|
+
self._log_command_result(command, status, stdout, stderr, exp_status, logging_pattern)
|
|
600
|
+
|
|
601
|
+
if isinstance(exp_status, int):
|
|
602
|
+
if status != exp_status:
|
|
603
|
+
break
|
|
604
|
+
elif status not in exp_status:
|
|
605
|
+
break
|
|
606
|
+
|
|
607
|
+
if completion_toctx.final_attempt:
|
|
608
|
+
what_for="command continued to succeed"
|
|
609
|
+
details = [
|
|
610
|
+
"CMD: %s" % command,
|
|
611
|
+
"STDOUT:",
|
|
612
|
+
indent_lines(stdout, 1),
|
|
613
|
+
"STDERR:",
|
|
614
|
+
indent_lines(stderr, 1),
|
|
615
|
+
]
|
|
616
|
+
toerr = completion_toctx.create_timeout(what_for=what_for, detail=details)
|
|
617
|
+
raise toerr
|
|
618
|
+
|
|
619
|
+
elif not completion_toctx.should_continue():
|
|
620
|
+
completion_toctx.mark_final_attempt()
|
|
621
|
+
|
|
622
|
+
time.sleep(completion_toctx.interval)
|
|
623
|
+
|
|
624
|
+
elif aspects.action_pattern == ActionPattern.SINGLE_CONNECTED_CALL or aspects.action_pattern == ActionPattern.DO_UNTIL_CONNECTION_FAILURE:
|
|
625
|
+
errmsg = "SshAgent and SshSession currently do not support the SINGLE_CONNECTED_CALL or DO_UNTIL_CONNECTION_FAILURE action patterns."
|
|
626
|
+
raise SemanticError(errmsg) from None
|
|
627
|
+
else:
|
|
628
|
+
errmsg = "SshBase: Unknown ActionPattern encountered. action_pattern={}".format(aspects.action_pattern)
|
|
629
|
+
raise SemanticError(errmsg) from None
|
|
630
|
+
|
|
631
|
+
return status, stdout, stderr
|
|
632
|
+
|
|
633
|
+
def _file_exists(self, ssh_client: SshClient, remotepath: str) -> bool:
|
|
634
|
+
"""
|
|
635
|
+
Private helper method used to check if the remote file exists.
|
|
636
|
+
"""
|
|
637
|
+
exists = False
|
|
638
|
+
|
|
639
|
+
if not self._primitive:
|
|
640
|
+
# transport = ssh_client.get_transport()
|
|
641
|
+
|
|
642
|
+
sftp = ssh_client.open_sftp()
|
|
643
|
+
try:
|
|
644
|
+
fsresult = sftp.stat(remotepath)
|
|
645
|
+
if stat.S_ISREG(fsresult.st_mode):
|
|
646
|
+
exists = True
|
|
647
|
+
except (FileNotFoundError, IOError):
|
|
648
|
+
exists = False
|
|
649
|
+
finally:
|
|
650
|
+
sftp.close()
|
|
651
|
+
else:
|
|
652
|
+
exists = primitive_file_exists(ssh_client, remotepath)
|
|
653
|
+
|
|
654
|
+
return exists
|
|
655
|
+
|
|
656
|
+
def _file_pull(self, ssh_client: SshClient, remotepath: str, localpath: str):
|
|
657
|
+
"""
|
|
658
|
+
Private helper method used to pull a remote file to a local file path.
|
|
659
|
+
"""
|
|
660
|
+
if not self._primitive:
|
|
661
|
+
# transport = ssh_client.get_transport()
|
|
662
|
+
|
|
663
|
+
sftp = ssh_client.open_sftp()
|
|
664
|
+
try:
|
|
665
|
+
sftp.get(remotepath, localpath)
|
|
666
|
+
finally:
|
|
667
|
+
sftp.close()
|
|
668
|
+
|
|
669
|
+
else:
|
|
670
|
+
primitive_file_pull(ssh_client, remotepath, localpath)
|
|
671
|
+
|
|
672
|
+
return
|
|
673
|
+
|
|
674
|
+
def _file_push(self, ssh_client: SshClient, localpath: str, remotepath: str):
|
|
675
|
+
"""
|
|
676
|
+
Private helper method used to push a local file to a remote file path.
|
|
677
|
+
"""
|
|
678
|
+
if not self._primitive:
|
|
679
|
+
# transport = ssh_client.get_transport()
|
|
680
|
+
|
|
681
|
+
sftp = ssh_client.open_sftp()
|
|
682
|
+
try:
|
|
683
|
+
sftp.put(localpath, remotepath)
|
|
684
|
+
finally:
|
|
685
|
+
sftp.close()
|
|
686
|
+
|
|
687
|
+
else:
|
|
688
|
+
primitive_file_push(ssh_client, localpath, remotepath)
|
|
689
|
+
|
|
690
|
+
return
|
|
691
|
+
|
|
692
|
+
def _get_supported_key_types(self) -> list[Tuple[str, type]]:
|
|
693
|
+
"""
|
|
694
|
+
Build a list of supported SSH key types in preference order.
|
|
695
|
+
|
|
696
|
+
:returns: A list of tuples containing key type labels and classes.
|
|
697
|
+
"""
|
|
698
|
+
preferred_key_types = [
|
|
699
|
+
("RSA", SshRSAKey),
|
|
700
|
+
("Ed25519", SshEd25519Key)
|
|
701
|
+
# ("ECDSA", getattr(paramiko, "ECDSAKey", None)),
|
|
702
|
+
# ("DSS", getattr(paramiko, "DSSKey", None))
|
|
703
|
+
]
|
|
704
|
+
|
|
705
|
+
key_types: list[Tuple[str, type]] = []
|
|
706
|
+
for key_name, key_type in preferred_key_types:
|
|
707
|
+
if key_type is not None:
|
|
708
|
+
key_types.append((key_name, key_type))
|
|
709
|
+
|
|
710
|
+
return key_types
|
|
711
|
+
|
|
712
|
+
def _load_key(self, keyraw: str, keypasswd: str):
|
|
713
|
+
"""
|
|
714
|
+
Load a private key from raw text.
|
|
715
|
+
|
|
716
|
+
:param keyraw: Raw private key content.
|
|
717
|
+
:param keypasswd: Password protecting the key.
|
|
718
|
+
|
|
719
|
+
:returns: Loaded SSH key object.
|
|
720
|
+
"""
|
|
721
|
+
keybuffer = StringIO(keyraw)
|
|
722
|
+
|
|
723
|
+
# Try different key types in order of common usage
|
|
724
|
+
key_types = self._get_supported_key_types()
|
|
725
|
+
if not key_types:
|
|
726
|
+
raise ConfigurationError("ERROR: No supported private key formats are available.")
|
|
727
|
+
|
|
728
|
+
for _, key_type in key_types:
|
|
729
|
+
try:
|
|
730
|
+
keybuffer.seek(0) # Reset buffer position for each attempt
|
|
731
|
+
pkey = key_type.from_private_key(keybuffer, password=keypasswd)
|
|
732
|
+
return pkey
|
|
733
|
+
except Exception:
|
|
734
|
+
continue
|
|
735
|
+
|
|
736
|
+
attempted_formats = ", ".join(name for name, _ in key_types)
|
|
737
|
+
errmsg = f"ERROR: Unable to load private key from raw content. Tried {attempted_formats} formats."
|
|
738
|
+
raise ConfigurationError(errmsg)
|
|
739
|
+
|
|
740
|
+
def _load_key_file(self, keyfile: str, keypasswd: str):
|
|
741
|
+
"""
|
|
742
|
+
Load a private key from a file path.
|
|
743
|
+
|
|
744
|
+
:param keyfile: Path to the key file.
|
|
745
|
+
:param keypasswd: Password protecting the key.
|
|
746
|
+
|
|
747
|
+
:returns: Loaded SSH key object.
|
|
748
|
+
"""
|
|
749
|
+
keyfile = os.path.expanduser(os.path.expandvars(keyfile))
|
|
750
|
+
|
|
751
|
+
# Try different key types in order of common usage
|
|
752
|
+
key_types = self._get_supported_key_types()
|
|
753
|
+
if not key_types:
|
|
754
|
+
raise ConfigurationError("ERROR: No supported private key formats are available.")
|
|
755
|
+
|
|
756
|
+
for _, key_type in key_types:
|
|
757
|
+
try:
|
|
758
|
+
pkey = key_type.from_private_key_file(keyfile, password=keypasswd)
|
|
759
|
+
return pkey
|
|
760
|
+
except Exception:
|
|
761
|
+
continue
|
|
762
|
+
|
|
763
|
+
attempted_formats = ", ".join(name for name, _ in key_types)
|
|
764
|
+
errmsg = f"ERROR: Unable to load private keyfile={keyfile}. Tried {attempted_formats} formats."
|
|
765
|
+
raise ConfigurationError(errmsg)
|
|
766
|
+
|
|
767
|
+
def _ssh_execute_command(self, ssh_runner, command: str, pty_params=None, inactivity_timeout: float=DEFAULT_SSH_TIMEOUT, inactivity_interval: float=DEFAULT_SSH_RETRY_INTERVAL, chunk_size: int=1024) -> Tuple[int, str, str]: # pylint: disable=no-self-use
|
|
768
|
+
"""
|
|
769
|
+
Private helper method used to route the command running parameters to the correct routine based on whether the command is being run interactively
|
|
770
|
+
through a session or is a single run command from an SshAgent.
|
|
771
|
+
|
|
772
|
+
:param ssh_runner: A :class:`SshClient` or :class:`SshChannel` object that can be used to run a command on a remote machine.
|
|
773
|
+
:param command: The command that is to be run.
|
|
774
|
+
:param pty_params: A dictionary of parameters that are passed to paramiko to get a pty when running commands.
|
|
775
|
+
:param inactivity_timeout: A timeout for inactivity between the local machine and remote machine.
|
|
776
|
+
:param inactivity_interval: The interval to wait between attempts to interact or read from the remote machine.
|
|
777
|
+
:param chunk_size: The size of the buffer to use when reading results from the remote machine.
|
|
778
|
+
"""
|
|
779
|
+
status, stdout, stderr = ssh_execute_command(ssh_runner, command, pty_params=pty_params, inactivity_timeout=inactivity_timeout, inactivity_interval=inactivity_interval, chunk_size=chunk_size)
|
|
780
|
+
return status, stdout, stderr
|