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.
@@ -0,0 +1,3 @@
1
+ """
2
+ SSH implementation package.
3
+ """
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