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,695 @@
|
|
|
1
|
+
|
|
2
|
+
"""
|
|
3
|
+
.. module:: sshhelpers
|
|
4
|
+
:platform: Darwin, Linux, Unix, Windows
|
|
5
|
+
:synopsis: sshhelpers module.
|
|
6
|
+
|
|
7
|
+
.. moduleauthor:: Myron Walker <myron.walker@gmail.com>
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__author__ = "Myron Walker"
|
|
11
|
+
__copyright__ = "Copyright 2025, Code Craft AI LLC"
|
|
12
|
+
__credits__ = []
|
|
13
|
+
|
|
14
|
+
from typing import Callable, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
from ccai.xmodules.xlogging.enhancedloggers import getEnhancedLogger
|
|
17
|
+
import os
|
|
18
|
+
import shlex
|
|
19
|
+
import stat
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
from ccai.protocols.ssh.sshclient import SshClient
|
|
23
|
+
from ccai.protocols.ssh.sshconnection import SshChannel
|
|
24
|
+
from ccai.protocols.ssh.sshsftp import SshSFTPClient, SshSFTPAttributes
|
|
25
|
+
|
|
26
|
+
from ccai.errors.exceptions import CommandError
|
|
27
|
+
|
|
28
|
+
from ccai.xmodules.xformatting import indent_lines, format_command_result
|
|
29
|
+
|
|
30
|
+
from ccai.protocols.ssh.sshconst import (
|
|
31
|
+
DEFAULT_SSH_TIMEOUT,
|
|
32
|
+
DEFAULT_SSH_RETRY_INTERVAL,
|
|
33
|
+
INTERACTIVE_PROMPT,
|
|
34
|
+
INTERACTIVE_PROMPT_BYTES,
|
|
35
|
+
REGEX_DIRECTORY_ENTRY
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = getEnhancedLogger()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def lookup_entry_type(pdir: str, ename: str, finfo: SshSFTPAttributes) -> str:
|
|
42
|
+
"""
|
|
43
|
+
Determines the entry type labelto assign to a file entry based on the st_mode of the file information.
|
|
44
|
+
|
|
45
|
+
:param pdir: The parent directory of the file, passed for logging purposes.
|
|
46
|
+
:param ename: The entry name of the file in the directory listing.
|
|
47
|
+
:param finfo: The file information as returned by lstat on the file entry.
|
|
48
|
+
|
|
49
|
+
:returns: A label indicating the type of directory entry.
|
|
50
|
+
"""
|
|
51
|
+
etype = None
|
|
52
|
+
if stat.S_ISREG(finfo.st_mode):
|
|
53
|
+
etype = "file"
|
|
54
|
+
elif stat.S_ISDIR(finfo.st_mode):
|
|
55
|
+
etype = "dir"
|
|
56
|
+
elif stat.S_ISLNK(finfo.st_mode):
|
|
57
|
+
etype = "link"
|
|
58
|
+
elif stat.S_ISCHR(finfo.st_mode):
|
|
59
|
+
etype = "char"
|
|
60
|
+
elif stat.S_ISBLK(finfo.st_mode):
|
|
61
|
+
etype = "block"
|
|
62
|
+
else:
|
|
63
|
+
print ("ERROR: Unknown entry type. pdir=%s, ename=%s" % (pdir, ename))
|
|
64
|
+
|
|
65
|
+
return etype
|
|
66
|
+
|
|
67
|
+
def primitive_list_directory(ssh_client: SshClient, directory: str) -> dict:
|
|
68
|
+
"""
|
|
69
|
+
Uses a primitive method to create a list of the files and folders in a directory
|
|
70
|
+
by running the 'ls -al' commands on the directory. This is required if the SSH
|
|
71
|
+
server does not support sftp.
|
|
72
|
+
|
|
73
|
+
:param ssh_client: A :class:`paramiko.SSHClient` object that has already been connected
|
|
74
|
+
to the remote server.
|
|
75
|
+
:param directory: The remote directory to get a list catalog for.
|
|
76
|
+
|
|
77
|
+
:returns: A dictionary with the information about the items in the target directory.
|
|
78
|
+
"""
|
|
79
|
+
entries = None
|
|
80
|
+
|
|
81
|
+
ls_cmd = "ls -al %s" % shlex.quote(directory)
|
|
82
|
+
|
|
83
|
+
status, stdout, stderr = ssh_execute_command(ssh_client, ls_cmd)
|
|
84
|
+
|
|
85
|
+
if status == 0:
|
|
86
|
+
entries = primitive_parse_directory_listing(directory, stdout)
|
|
87
|
+
else:
|
|
88
|
+
# Allow permission denied errors
|
|
89
|
+
if stderr.find("Permission denied") < 0:
|
|
90
|
+
errmsg = "Error attempting to get a primitive directory listing.\n CMD=%s\n STDERR:\n%s" % (ls_cmd, stderr)
|
|
91
|
+
raise Exception(errmsg)
|
|
92
|
+
|
|
93
|
+
return entries
|
|
94
|
+
|
|
95
|
+
def primitive_list_tree(ssh_client: SshClient, treeroot: str, max_depth: int=1) -> dict:
|
|
96
|
+
"""
|
|
97
|
+
Uses a primitive method to create an information tree about the files and folders
|
|
98
|
+
in a directory tree by running 'ls -al' commands on the directory tree. This is
|
|
99
|
+
required if the SSH server does not support sftp.
|
|
100
|
+
|
|
101
|
+
:param ssh_client: A :class:`paramiko.SSHClient` object that has already been connected
|
|
102
|
+
to the remote server.
|
|
103
|
+
:param treeroot: The remote root directory to get a directory tree catalog for.
|
|
104
|
+
:param max_depth: The maximum descent depth to go to when building the tree catalog.
|
|
105
|
+
|
|
106
|
+
:returns: A dictionary with the information about the root directory and its children.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
level_items = primitive_list_directory(ssh_client, treeroot)
|
|
110
|
+
|
|
111
|
+
if max_depth > 1:
|
|
112
|
+
if not treeroot.endswith("/"):
|
|
113
|
+
treeroot = treeroot + "/"
|
|
114
|
+
|
|
115
|
+
for nxtitem in level_items.values():
|
|
116
|
+
nxtname = nxtitem["name"]
|
|
117
|
+
if nxtitem["type"] == "dir":
|
|
118
|
+
nxtroot = treeroot + nxtname
|
|
119
|
+
nxtchildren = primitive_list_tree_recurse(ssh_client, nxtroot, max_depth - 1)
|
|
120
|
+
nxtitem.update(nxtchildren)
|
|
121
|
+
|
|
122
|
+
tree_info = {
|
|
123
|
+
"name": treeroot,
|
|
124
|
+
"items": level_items
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return tree_info
|
|
128
|
+
|
|
129
|
+
def primitive_list_tree_recurse(ssh_client: SshClient, targetdir: str, remaining: int) -> dict:
|
|
130
|
+
"""
|
|
131
|
+
Method which is called recursively to list the items in a directory and to form a tree
|
|
132
|
+
of information about the files under a room directory. This method utilizes primitive
|
|
133
|
+
shell commands in order to get directory information and is used when the SSH server on a
|
|
134
|
+
device does not support FTP based access or file queries
|
|
135
|
+
|
|
136
|
+
:param ssh_client: A paramiko.SSHClient to use for running commands to discover the information
|
|
137
|
+
about a directory.
|
|
138
|
+
:param targetdir: The target directory of the local tree to scan.
|
|
139
|
+
:param remaining: The remaining levels to descend
|
|
140
|
+
|
|
141
|
+
:returns: A dictionary with the information about the target directory and its children.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
level_items = primitive_list_directory(ssh_client, targetdir)
|
|
145
|
+
|
|
146
|
+
if remaining > 1:
|
|
147
|
+
if not targetdir.endswith("/"):
|
|
148
|
+
targetdir = targetdir + "/"
|
|
149
|
+
|
|
150
|
+
for nxtitem in level_items.values():
|
|
151
|
+
nxtname = nxtitem["name"]
|
|
152
|
+
if nxtitem["type"] == "dir":
|
|
153
|
+
nxttarget = targetdir + nxtname
|
|
154
|
+
nxtchildren = primitive_list_tree_recurse(ssh_client, nxttarget, remaining - 1)
|
|
155
|
+
nxtitem.update(nxtchildren)
|
|
156
|
+
|
|
157
|
+
children_info = {
|
|
158
|
+
"items": level_items
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return children_info
|
|
162
|
+
|
|
163
|
+
def primitive_parse_directory_listing(listing_dir:str, content: str) -> dict:
|
|
164
|
+
"""
|
|
165
|
+
Method which is called parse the information associated with a directory listing.
|
|
166
|
+
|
|
167
|
+
:param listing_dir: The directory path associated with the directory listing content being parsed.
|
|
168
|
+
:param content: The directory listing content to be parsed.
|
|
169
|
+
|
|
170
|
+
:returns: A dictionary with the directory entry items from a directory based on the directory listing content being parsed.
|
|
171
|
+
"""
|
|
172
|
+
entries = {}
|
|
173
|
+
|
|
174
|
+
listing_lines = content.splitlines(False)
|
|
175
|
+
for nxtline in listing_lines:
|
|
176
|
+
nxtline = nxtline.strip()
|
|
177
|
+
|
|
178
|
+
mobj = REGEX_DIRECTORY_ENTRY.match(nxtline)
|
|
179
|
+
if mobj is not None:
|
|
180
|
+
mgroups = mobj.groups()
|
|
181
|
+
|
|
182
|
+
name = mgroups[6]
|
|
183
|
+
if name == "." or name == "..":
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
perms = mgroups[0]
|
|
187
|
+
|
|
188
|
+
perms_tc = perms[0]
|
|
189
|
+
etype = None
|
|
190
|
+
link_target = None
|
|
191
|
+
if perms_tc == '-':
|
|
192
|
+
etype = "file"
|
|
193
|
+
elif perms_tc == 'd':
|
|
194
|
+
etype = "dir"
|
|
195
|
+
elif perms_tc == 'l':
|
|
196
|
+
etype = "link"
|
|
197
|
+
# For symlinks, the name field contains "linkname -> target"
|
|
198
|
+
# We need to find the last " -> " occurrence to properly separate them
|
|
199
|
+
arrow_pos = name.rfind(" -> ")
|
|
200
|
+
if arrow_pos != -1:
|
|
201
|
+
link_target = name[arrow_pos + 4:] # +4 to skip " -> "
|
|
202
|
+
name = name[:arrow_pos]
|
|
203
|
+
# If no arrow, keep the original name (shouldn't happen in normal ls output)
|
|
204
|
+
elif perms_tc == 'c':
|
|
205
|
+
etype = "char"
|
|
206
|
+
elif perms_tc == 'b':
|
|
207
|
+
etype = "block"
|
|
208
|
+
else:
|
|
209
|
+
raise Exception("Unknown directory listing entry type. dir=%s tc=%s" % (listing_dir, perms_tc))
|
|
210
|
+
|
|
211
|
+
dentry = {
|
|
212
|
+
"name": name,
|
|
213
|
+
"perms": perms,
|
|
214
|
+
"type": etype,
|
|
215
|
+
"owner": mgroups[2],
|
|
216
|
+
"group": mgroups[3],
|
|
217
|
+
"size": mgroups[4],
|
|
218
|
+
"modified": mgroups[5]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# Add link target for symlinks
|
|
222
|
+
if etype == "link" and link_target is not None:
|
|
223
|
+
dentry["link_target"] = link_target
|
|
224
|
+
|
|
225
|
+
entries[name] = dentry
|
|
226
|
+
|
|
227
|
+
return entries
|
|
228
|
+
|
|
229
|
+
def primitive_directory_exists(ssh_client: SshClient, remotedir: str) -> bool:
|
|
230
|
+
"""
|
|
231
|
+
Checks to see if the specified directory exists.
|
|
232
|
+
|
|
233
|
+
:param ssh_client: A paramiko.SSHClient to run commands over.
|
|
234
|
+
:param remotedir: The path of the remote directory to check for existence.
|
|
235
|
+
|
|
236
|
+
:returns: A boolean indicating if the directory exists.
|
|
237
|
+
"""
|
|
238
|
+
exists = False
|
|
239
|
+
|
|
240
|
+
existscmd = 'test -d {0} && echo exists'.format(shlex.quote(remotedir))
|
|
241
|
+
status, stdout, stderr = ssh_execute_command(ssh_client, existscmd)
|
|
242
|
+
|
|
243
|
+
if status == 0:
|
|
244
|
+
exists = stdout.strip() == 'exists'
|
|
245
|
+
|
|
246
|
+
return exists
|
|
247
|
+
|
|
248
|
+
def primitive_file_exists(ssh_client: SshClient, remotepath: str) -> bool:
|
|
249
|
+
"""
|
|
250
|
+
Checks to see if the specified file exists.
|
|
251
|
+
|
|
252
|
+
:param ssh_client: A paramiko.SSHClient to run commands over.
|
|
253
|
+
:param remotepath: The path of the remote file to check for existence.
|
|
254
|
+
|
|
255
|
+
:returns: A boolean indicating if the file exists.
|
|
256
|
+
"""
|
|
257
|
+
exists = False
|
|
258
|
+
|
|
259
|
+
existscmd = 'test -f {0} && echo exists'.format(shlex.quote(remotepath))
|
|
260
|
+
status, stdout, stderr = ssh_execute_command(ssh_client, existscmd)
|
|
261
|
+
|
|
262
|
+
if status == 0:
|
|
263
|
+
exists = stdout.strip() == 'exists'
|
|
264
|
+
|
|
265
|
+
return exists
|
|
266
|
+
|
|
267
|
+
def primitive_file_pull(ssh_client: SshClient, remotepath: str, localpath: str):
|
|
268
|
+
"""
|
|
269
|
+
Pulls the contents of a file to a local path using a primitive 'cat' command.
|
|
270
|
+
|
|
271
|
+
:param ssh_client: A paramiko.SSHClient to run commands over.
|
|
272
|
+
:param remotepath: The path to the file on the remote machine.
|
|
273
|
+
:param localpath: The local path to a file to write the contents of the remote file too.
|
|
274
|
+
"""
|
|
275
|
+
copycmd = "cat %s" % shlex.quote(remotepath)
|
|
276
|
+
|
|
277
|
+
status, stdout, stderr = ssh_execute_command(ssh_client, copycmd, decode=False)
|
|
278
|
+
|
|
279
|
+
if status != 0:
|
|
280
|
+
stderr = stderr.decode()
|
|
281
|
+
errmsg_lines = [
|
|
282
|
+
"Error pulling file using command. cmd=%s" % copycmd,
|
|
283
|
+
"STDERR:",
|
|
284
|
+
indent_lines(stderr, 1)
|
|
285
|
+
]
|
|
286
|
+
raise Exception(os.linesep.join(errmsg_lines))
|
|
287
|
+
|
|
288
|
+
with open(localpath, 'wb') as lfile:
|
|
289
|
+
lfile.write(stdout)
|
|
290
|
+
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
def primitive_file_push(ssh_client: SshClient, localpath: str, remotepath: str, read_size: int=1024):
|
|
294
|
+
"""
|
|
295
|
+
Pushes the contents of a local file to a file on a remote machine at the path specified by remotepath.
|
|
296
|
+
|
|
297
|
+
:param ssh_client: A paramiko.SSHClient to run commands over.
|
|
298
|
+
:param localpath: The local path to a file to read contents from.
|
|
299
|
+
:param remotepath: The path to file on the remote machine to write the contents of the local file too.
|
|
300
|
+
:param read_size: The chunk size to use for reading the local file and sending data.
|
|
301
|
+
"""
|
|
302
|
+
cat_cmd = "cat > {}".format(shlex.quote(remotepath))
|
|
303
|
+
|
|
304
|
+
# Read the entire file - ssh_execute_command handles transmission efficiently
|
|
305
|
+
with open(localpath, "rb") as file:
|
|
306
|
+
content = file.read()
|
|
307
|
+
|
|
308
|
+
status, stdout, stderr = ssh_execute_command(ssh_client, command=cat_cmd, chunk_size=read_size, input=content, decode=False)
|
|
309
|
+
if status != 0:
|
|
310
|
+
errmsg = "Error pushing file={} to remote path.".format(remotepath)
|
|
311
|
+
fmt_errmsg = format_command_result(errmsg, cat_cmd, status, stdout.decode(), stderr.decode())
|
|
312
|
+
raise CommandError(fmt_errmsg)
|
|
313
|
+
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
def sftp_list_directory(sftp, directory, userlookup, grouplookup) -> dict:
|
|
317
|
+
"""
|
|
318
|
+
Gets a listing of the directory entries of a remote directory.
|
|
319
|
+
|
|
320
|
+
:param sftp: A paramiko.SFTPClient that is connected to a remote machine.
|
|
321
|
+
:param directory: The remote directory to get the directory listing for.
|
|
322
|
+
:param userlookup: A callable function to lookup the user associated with a directory item by uid.
|
|
323
|
+
:param grouplookup: A callable function to lookup the group associated with a directory item by gid.
|
|
324
|
+
|
|
325
|
+
:returns: A dictionary with the information about the items in the target directory.
|
|
326
|
+
"""
|
|
327
|
+
entries = {}
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
sftp.chdir(directory)
|
|
331
|
+
directory_items = sftp.listdir()
|
|
332
|
+
|
|
333
|
+
for nname in directory_items:
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
ninfo = sftp.lstat(nname)
|
|
337
|
+
perms = ""
|
|
338
|
+
dentry = {
|
|
339
|
+
"name": nname,
|
|
340
|
+
"perms": perms,
|
|
341
|
+
"type": lookup_entry_type(directory, nname, ninfo),
|
|
342
|
+
"owner": userlookup(ninfo.st_uid),
|
|
343
|
+
"group": grouplookup(ninfo.st_gid),
|
|
344
|
+
"size": ninfo.st_size,
|
|
345
|
+
"accessed": ninfo.st_atime,
|
|
346
|
+
"modified": ninfo.st_mtime
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
entries[nname] = dentry
|
|
350
|
+
except PermissionError:
|
|
351
|
+
logger.exception("Unable to perform lstat on remote directory item %r." % nname)
|
|
352
|
+
except PermissionError:
|
|
353
|
+
logger.exception("Unable to perform listdir on remote directory %r." % directory)
|
|
354
|
+
|
|
355
|
+
return entries
|
|
356
|
+
|
|
357
|
+
def sftp_list_tree(sftp: SshSFTPClient, treeroot: str, userlookup: Callable[[int, Optional[bool]], str], grouplookup: Callable[[int, Optional[bool]], str], max_depth: int=1) -> dict:
|
|
358
|
+
"""
|
|
359
|
+
Gets a tree of the directory entries of a remote directory.
|
|
360
|
+
|
|
361
|
+
:param sftp: A paramiko.SFTPClient that is connected to a remote machine.
|
|
362
|
+
:param treeroot: The root directory to get the directory listing for.
|
|
363
|
+
:param userlookup: A callable function to lookup the user associated with a directory item by uid.
|
|
364
|
+
:param grouplookup: A callable function to lookup the group associated with a directory item by gid.
|
|
365
|
+
:param max_depth: The maximum depth to create the directory tree.
|
|
366
|
+
|
|
367
|
+
:returns: A dictionary with the information about the root directory and its children.
|
|
368
|
+
"""
|
|
369
|
+
level_items = sftp_list_directory(sftp, treeroot, userlookup, grouplookup)
|
|
370
|
+
|
|
371
|
+
if max_depth > 1:
|
|
372
|
+
if not treeroot.endswith("/"):
|
|
373
|
+
treeroot = treeroot + "/"
|
|
374
|
+
|
|
375
|
+
for nxtitem in level_items.values():
|
|
376
|
+
nxtname = nxtitem["name"]
|
|
377
|
+
if nxtitem["type"] == "dir":
|
|
378
|
+
nxtroot = treeroot + nxtname
|
|
379
|
+
nxtchildren = sftp_list_tree_recurse(sftp, nxtroot, userlookup, grouplookup, max_depth - 1)
|
|
380
|
+
nxtitem.update(nxtchildren)
|
|
381
|
+
|
|
382
|
+
tree_info = {
|
|
383
|
+
"name": treeroot,
|
|
384
|
+
"items": level_items
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return tree_info
|
|
388
|
+
|
|
389
|
+
def sftp_list_tree_recurse(sftp: SshSFTPClient, targetdir: str, userlookup: Callable[[int, Optional[bool]], str], grouplookup: Callable[[int, Optional[bool]], str], remaining: int) -> dict:
|
|
390
|
+
"""
|
|
391
|
+
Gets a listing of the directory passed and then recursively calls itself to create sub directory listings up to the remaining depth.
|
|
392
|
+
|
|
393
|
+
:param sftp: A paramiko.SFTPClient that is connected to a remote machine.
|
|
394
|
+
:param targetdir: The target directory to get the directory tree listing for.
|
|
395
|
+
:param userlookup: A callable function to lookup the user associated with a directory item by uid.
|
|
396
|
+
:param grouplookup: A callable function to lookup the group associated with a directory item by gid.
|
|
397
|
+
:param remaining: The depth remaining to create the directory tree.
|
|
398
|
+
|
|
399
|
+
:returns: A dictionary with the information about the target directory and its children.
|
|
400
|
+
"""
|
|
401
|
+
level_items = sftp_list_directory(sftp, targetdir, userlookup, grouplookup)
|
|
402
|
+
|
|
403
|
+
if remaining > 1:
|
|
404
|
+
if not targetdir.endswith("/"):
|
|
405
|
+
targetdir = targetdir + "/"
|
|
406
|
+
|
|
407
|
+
for nxtitem in level_items.values():
|
|
408
|
+
nxtname = nxtitem["name"]
|
|
409
|
+
if nxtitem["type"] == "dir":
|
|
410
|
+
nxtroot = targetdir + nxtname
|
|
411
|
+
nxtchildren = sftp_list_tree_recurse(sftp, nxtroot, userlookup, grouplookup, remaining - 1)
|
|
412
|
+
nxtitem.update(nxtchildren)
|
|
413
|
+
|
|
414
|
+
children_info = {
|
|
415
|
+
"items": level_items
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return children_info
|
|
419
|
+
|
|
420
|
+
def ssh_execute_command(ssh_client: SshClient, command: str, pty_params=None,
|
|
421
|
+
inactivity_timeout: float=DEFAULT_SSH_TIMEOUT, inactivity_interval: float=DEFAULT_SSH_RETRY_INTERVAL,
|
|
422
|
+
chunk_size: int=1024, input: Optional[str]=None, decode=True) -> Tuple[int, str, str]:
|
|
423
|
+
"""
|
|
424
|
+
Runs a command on a remote server using the specified ssh_client. We implement our own version of ssh_execute_command
|
|
425
|
+
in order to have better control over the timeouts and to make sure all the checks are sequenced properly in order
|
|
426
|
+
to prevent SSH lockups.
|
|
427
|
+
|
|
428
|
+
:param ssh_client: The :class:`paramiko.SSHClient` object to utilize when running the command.
|
|
429
|
+
:param command: The commandline to run.
|
|
430
|
+
:param inactivity_timeout: The timeout for the channel for the ssh transaction.
|
|
431
|
+
:param inactivity_interval: The retry interval to wait for the channel to have data ready.
|
|
432
|
+
:param chunk_size: The size of the chunks that are read during receive operations
|
|
433
|
+
|
|
434
|
+
:returns: A tuple with the command result code, the standard output and the standard error output.
|
|
435
|
+
"""
|
|
436
|
+
status = None
|
|
437
|
+
stdout_buffer = bytearray()
|
|
438
|
+
stderr_buffer = bytearray()
|
|
439
|
+
|
|
440
|
+
start_time = time.time()
|
|
441
|
+
end_time = start_time + inactivity_timeout
|
|
442
|
+
|
|
443
|
+
# ssh_client.get_transport().open_session(timeout=inactivity_timeout)
|
|
444
|
+
# SshClient has open_sftp? No, we need channel.
|
|
445
|
+
# self.connection.open_channel("session") in SshClient?
|
|
446
|
+
# Actually SshClient.get_transport() returns Transport.
|
|
447
|
+
# Transport does NOT have open_session (SshConnection does).
|
|
448
|
+
# SshClient should probably expose open_channel?
|
|
449
|
+
# Or use ssh_client.invoke_shell() or something?
|
|
450
|
+
# My SshClient.invoke_shell returns channel.
|
|
451
|
+
|
|
452
|
+
# Let's assume ssh_client.connection.open_channel("session")
|
|
453
|
+
# or expose it in SshClient.
|
|
454
|
+
|
|
455
|
+
channel = ssh_client.connection.open_channel("session")
|
|
456
|
+
# Set timeout? My Channel doesn't support setting timeout on object yet?
|
|
457
|
+
# It supports timeout in recv().
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
if pty_params is not None:
|
|
461
|
+
channel.request_pty(**pty_params)
|
|
462
|
+
|
|
463
|
+
channel.request_exec(command)
|
|
464
|
+
|
|
465
|
+
input_length = 0
|
|
466
|
+
if input is not None:
|
|
467
|
+
if isinstance(input, str):
|
|
468
|
+
input = input.encode()
|
|
469
|
+
input_length = len(input)
|
|
470
|
+
input_pos = 0
|
|
471
|
+
input_shutdown = False
|
|
472
|
+
|
|
473
|
+
while True:
|
|
474
|
+
# Grab exit status ready before checking to see if stdout_ready or stderr_ready are True
|
|
475
|
+
exit_status_ready = channel.exit_status_ready()
|
|
476
|
+
stdout_ready = channel.recv_ready()
|
|
477
|
+
stderr_ready = channel.recv_stderr_ready()
|
|
478
|
+
|
|
479
|
+
# If stdout_ready or stderr_ready are true, we read this round
|
|
480
|
+
if stdout_ready or stderr_ready:
|
|
481
|
+
if stdout_ready:
|
|
482
|
+
rcv_data = channel.recv(chunk_size)
|
|
483
|
+
stdout_buffer.extend(rcv_data)
|
|
484
|
+
if stderr_ready:
|
|
485
|
+
rcv_data = channel.recv_stderr(chunk_size)
|
|
486
|
+
stderr_buffer.extend(rcv_data)
|
|
487
|
+
|
|
488
|
+
# We only want to timeout if there is inactivity
|
|
489
|
+
start_time = time.time()
|
|
490
|
+
end_time = start_time + inactivity_timeout
|
|
491
|
+
|
|
492
|
+
# If there is data left to send and the send channel is ready
|
|
493
|
+
# for input, send a chunk from the input buffer
|
|
494
|
+
elif input_length > 0 and input_pos < input_length and channel.send_ready():
|
|
495
|
+
|
|
496
|
+
send_size = chunk_size
|
|
497
|
+
avail_length = input_length - input_pos
|
|
498
|
+
if avail_length < chunk_size:
|
|
499
|
+
send_size = avail_length
|
|
500
|
+
|
|
501
|
+
send_chunk = input[input_pos: input_pos + send_size]
|
|
502
|
+
# Bounded by the aspects' inactivity timeout in case the peer's channel window is exhausted.
|
|
503
|
+
sent_size = channel.send(send_chunk, timeout=inactivity_timeout)
|
|
504
|
+
|
|
505
|
+
input_pos = input_pos + sent_size
|
|
506
|
+
|
|
507
|
+
# If we have written everything there is to write, and writting to the
|
|
508
|
+
# channel has not been shutdown, then shutdown writing.
|
|
509
|
+
elif input is not None and not input_shutdown and input_pos >= input_length:
|
|
510
|
+
channel.shutdown_write()
|
|
511
|
+
input_shutdown = True
|
|
512
|
+
|
|
513
|
+
# If we did not read any data this round and our exit status was ready, its time to exit
|
|
514
|
+
elif exit_status_ready:
|
|
515
|
+
status = channel.recv_exit_status()
|
|
516
|
+
break
|
|
517
|
+
# We didn't have any data to read and our exit status was not ready, go to sleep for interval
|
|
518
|
+
else:
|
|
519
|
+
now_time = time.time()
|
|
520
|
+
if now_time > end_time:
|
|
521
|
+
diff_time = now_time - start_time
|
|
522
|
+
peername = ssh_client.get_transport().sock.getpeername()
|
|
523
|
+
err_msg = "Timeout while executing SSH command.\n peer=%r command=%s\n start=%r end=%r now=%r diff=%r" % (
|
|
524
|
+
peername, command, start_time, end_time, now_time, diff_time)
|
|
525
|
+
raise TimeoutError(err_msg)
|
|
526
|
+
|
|
527
|
+
# If we did not timeout, go to sleep for the interval before we make another attempt to read
|
|
528
|
+
time.sleep(inactivity_interval)
|
|
529
|
+
|
|
530
|
+
# End while True
|
|
531
|
+
|
|
532
|
+
finally:
|
|
533
|
+
# Always close the channel to prevent resource leaks
|
|
534
|
+
try:
|
|
535
|
+
channel.close()
|
|
536
|
+
except Exception:
|
|
537
|
+
pass # Ignore errors when closing the channel
|
|
538
|
+
|
|
539
|
+
stdout = stdout_buffer
|
|
540
|
+
if decode:
|
|
541
|
+
stdout = stdout_buffer.decode()
|
|
542
|
+
del stdout_buffer
|
|
543
|
+
|
|
544
|
+
stderr = stderr_buffer
|
|
545
|
+
if decode:
|
|
546
|
+
stderr = stderr_buffer.decode()
|
|
547
|
+
del stderr_buffer
|
|
548
|
+
|
|
549
|
+
return status, stdout, stderr
|
|
550
|
+
|
|
551
|
+
def _ssh_execute_command_in_channel_imp(ssh_channel: SshChannel, command: str, read_timeout: float=2, inactivity_timeout: float=DEFAULT_SSH_TIMEOUT, inactivity_interval: float=DEFAULT_SSH_RETRY_INTERVAL, chunk_size: int=1024) -> Tuple[bytearray, bytearray]:
|
|
552
|
+
"""
|
|
553
|
+
Core implementation that executes a command in a channel and returns raw output buffers.
|
|
554
|
+
|
|
555
|
+
:param ssh_channel: The :class:`SshChannel` object to utilize when running the command.
|
|
556
|
+
:param command: The commandline to run.
|
|
557
|
+
:param read_timeout: Timeout for individual read operations.
|
|
558
|
+
:param inactivity_timeout: The timeout for the channel for the ssh transaction.
|
|
559
|
+
:param inactivity_interval: The retry interval to wait for the channel to have data ready.
|
|
560
|
+
:param chunk_size: The size of the chunks that are read during receive operations
|
|
561
|
+
|
|
562
|
+
:returns: A tuple with stdout_buffer and stderr_buffer as bytearrays.
|
|
563
|
+
"""
|
|
564
|
+
stdout_buffer = bytearray()
|
|
565
|
+
stderr_buffer = bytearray()
|
|
566
|
+
|
|
567
|
+
start_time = time.time()
|
|
568
|
+
end_time = start_time + inactivity_timeout
|
|
569
|
+
|
|
570
|
+
# Assuming ssh_channel.send() handles encoding if needed, but it usually takes bytes.
|
|
571
|
+
# sshconnection.Channel.send takes bytes.
|
|
572
|
+
# command is str.
|
|
573
|
+
command_bytes = (command + "\n").encode('utf-8')
|
|
574
|
+
# Bounded by the aspects' inactivity timeout in case the peer's channel window is exhausted.
|
|
575
|
+
ssh_channel.send(command_bytes, timeout=inactivity_timeout)
|
|
576
|
+
|
|
577
|
+
stdout_pipe_timeout = 0
|
|
578
|
+
stderr_pipe_timeout = 0
|
|
579
|
+
while True:
|
|
580
|
+
if stdout_pipe_timeout < 2:
|
|
581
|
+
rcv_data = ssh_channel.recv(chunk_size, timeout=read_timeout)
|
|
582
|
+
if rcv_data:
|
|
583
|
+
stdout_buffer.extend(rcv_data)
|
|
584
|
+
if rcv_data.endswith(INTERACTIVE_PROMPT_BYTES):
|
|
585
|
+
stdout_pipe_timeout = 2
|
|
586
|
+
else:
|
|
587
|
+
# Timeout or empty
|
|
588
|
+
stdout_pipe_timeout += 1
|
|
589
|
+
|
|
590
|
+
if stderr_pipe_timeout < 2:
|
|
591
|
+
# Do not wait for stderr: an interactive channel has a pty, which merges stderr into stdout, so waiting
|
|
592
|
+
# would only delay every command by the read timeout. Stderr that arrives later is drained below.
|
|
593
|
+
rcv_data = ssh_channel.recv_stderr(chunk_size, timeout=0)
|
|
594
|
+
if rcv_data:
|
|
595
|
+
stderr_buffer.extend(rcv_data)
|
|
596
|
+
else:
|
|
597
|
+
stderr_pipe_timeout += 1
|
|
598
|
+
|
|
599
|
+
if stdout_pipe_timeout > 1 and stderr_pipe_timeout > 1:
|
|
600
|
+
break
|
|
601
|
+
|
|
602
|
+
now_time = time.time()
|
|
603
|
+
if now_time > end_time:
|
|
604
|
+
errmsg = "Timeout while running command={}".format(command)
|
|
605
|
+
raise TimeoutError(errmsg)
|
|
606
|
+
|
|
607
|
+
# Collect any stderr that arrived while stdout was still being read.
|
|
608
|
+
rcv_data = ssh_channel.recv_stderr(chunk_size, timeout=0)
|
|
609
|
+
while len(rcv_data) > 0:
|
|
610
|
+
stderr_buffer.extend(rcv_data)
|
|
611
|
+
rcv_data = ssh_channel.recv_stderr(chunk_size, timeout=0)
|
|
612
|
+
|
|
613
|
+
return stdout_buffer, stderr_buffer
|
|
614
|
+
|
|
615
|
+
def _process_channel_output(command: str, stdout_buffer: bytearray, stderr_buffer: bytearray) -> Tuple[str, str]:
|
|
616
|
+
"""
|
|
617
|
+
Processes the stdout and stderr buffers from a channel command execution, validating that
|
|
618
|
+
the command was echoed and terminated with the interactive prompt, then returning trimmed output.
|
|
619
|
+
"""
|
|
620
|
+
stdout = stdout_buffer.decode()
|
|
621
|
+
if not stdout.startswith(command):
|
|
622
|
+
errmsg = "The first line should have been an echo of our command."
|
|
623
|
+
raise Exception(errmsg)
|
|
624
|
+
|
|
625
|
+
if not stdout.endswith(INTERACTIVE_PROMPT):
|
|
626
|
+
errmsg = "The output should have ended with the interactive prompt."
|
|
627
|
+
raise Exception(errmsg)
|
|
628
|
+
|
|
629
|
+
stdout = stdout[len(command):-len(INTERACTIVE_PROMPT)].strip()
|
|
630
|
+
stderr = stderr_buffer.decode()
|
|
631
|
+
|
|
632
|
+
return stdout, stderr
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _get_exit_status_in_channel(ssh_channel: SshChannel, *, read_timeout: float, inactivity_timeout: float, inactivity_interval: float, chunk_size: int) -> int:
|
|
636
|
+
"""
|
|
637
|
+
Executes ``echo $?`` in the provided channel and parses the resulting exit status without
|
|
638
|
+
re-entering :func:`ssh_execute_command_in_channel`, avoiding recursion concerns.
|
|
639
|
+
"""
|
|
640
|
+
status_stdout_buffer, status_stderr_buffer = _ssh_execute_command_in_channel_imp(
|
|
641
|
+
ssh_channel, "echo $?", read_timeout, inactivity_timeout, inactivity_interval, chunk_size
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
status_stdout, _ = _process_channel_output("echo $?", status_stdout_buffer, status_stderr_buffer)
|
|
645
|
+
|
|
646
|
+
status = 1
|
|
647
|
+
if status_stdout.isdigit():
|
|
648
|
+
status = int(status_stdout)
|
|
649
|
+
else:
|
|
650
|
+
logger.warning("Could not parse exit status from: %s", status_stdout)
|
|
651
|
+
|
|
652
|
+
return status
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def ssh_execute_command_in_channel(ssh_channel: SshChannel, command: str, read_timeout: float=2, inactivity_timeout: float=DEFAULT_SSH_TIMEOUT, inactivity_interval: float=DEFAULT_SSH_RETRY_INTERVAL, chunk_size: int=1024, status_check=False) -> Tuple[int, str, str]:
|
|
656
|
+
"""
|
|
657
|
+
Runs a command on a remote server using the specified ssh_channel. We implement our own version of ssh_execute_command
|
|
658
|
+
in order to have better control over the timeouts and to make sure all the checks are sequenced properly in order
|
|
659
|
+
to prevent SSH lockups.
|
|
660
|
+
|
|
661
|
+
:param ssh_channel: The :class:`paramiko.Channel` object to utilize when running the command.
|
|
662
|
+
:param command: The commandline to run.
|
|
663
|
+
:param read_timeout: Timeout for individual read operations.
|
|
664
|
+
:param inactivity_timeout: The timeout for the channel for the ssh transaction.
|
|
665
|
+
:param inactivity_interval: The retry interval to wait for the channel to have data ready.
|
|
666
|
+
:param chunk_size: The size of the chunks that are read during receive operations
|
|
667
|
+
:param status_check: If True, skip getting exit status (used internally)
|
|
668
|
+
|
|
669
|
+
:returns: A tuple with the command result code, the standard output and the standard error output.
|
|
670
|
+
"""
|
|
671
|
+
# Execute the main command
|
|
672
|
+
stdout_buffer, stderr_buffer = _ssh_execute_command_in_channel_imp(
|
|
673
|
+
ssh_channel, command, read_timeout, inactivity_timeout, inactivity_interval, chunk_size
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
status = 0 # Default to success
|
|
677
|
+
if not status_check:
|
|
678
|
+
# Get exit status by running echo $? immediately after the command
|
|
679
|
+
try:
|
|
680
|
+
status = _get_exit_status_in_channel(
|
|
681
|
+
ssh_channel,
|
|
682
|
+
read_timeout=read_timeout,
|
|
683
|
+
inactivity_timeout=inactivity_timeout,
|
|
684
|
+
inactivity_interval=inactivity_interval,
|
|
685
|
+
chunk_size=chunk_size
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
except Exception as exc:
|
|
689
|
+
# If status check fails, log warning and assume failure
|
|
690
|
+
logger.warning("Failed to get exit status for command '%s': %s", command, exc)
|
|
691
|
+
status = 1
|
|
692
|
+
|
|
693
|
+
stdout, stderr = _process_channel_output(command, stdout_buffer, stderr_buffer)
|
|
694
|
+
|
|
695
|
+
return status, stdout, stderr
|