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,464 @@
1
+ """
2
+ .. module:: sshtunnel
3
+ :platform: Darwin, Linux, Unix, Windows
4
+ :synopsis: Module containing the :class:`SshTunnel` 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 types import TracebackType
16
+ import socket
17
+ import select
18
+ import threading
19
+
20
+ from ccai.protocols.ssh.sshclient import SshClient
21
+ from ccai.protocols.ssh.sshconnection import SshConnection, SshChannel
22
+
23
+ from ccai.xmodules.xlogging.enhancedloggers import getEnhancedLogger
24
+
25
+ logger = getEnhancedLogger()
26
+
27
+ # How often, in seconds, each direction of a forwarded connection wakes up to check whether the tunnel is
28
+ # shutting down or the other direction has finished.
29
+ PUMP_POLL_INTERVAL = 1.0
30
+
31
+ # The number of bytes read from the local socket or the channel per forwarding step.
32
+ PUMP_CHUNK_SIZE = 32768
33
+
34
+ # How long, in seconds, a finished connection waits for its channel to socket forwarding thread to exit.
35
+ PUMP_JOIN_TIMEOUT = 5.0
36
+
37
+
38
+ class SshTunnel:
39
+ """
40
+ The :class:`SshTunnel` represents a single SSH port forwarding tunnel for connecting to a remote service
41
+ through an SSH connection. Each SshTunnel instance manages exactly one port forward.
42
+
43
+ Note: SshTunnel instances should only be created through SshAgent.open_tunnel() method.
44
+ """
45
+
46
+ def __init__(self, ssh_client: SshClient, local_port: int, remote_host: str, remote_port: int, _internal_create: bool = False):
47
+ """
48
+ Initialize an :class:`SshTunnel` instance with an existing SSH client and tunnel parameters.
49
+
50
+ Note: This constructor should not be called directly. Use SshAgent.open_tunnel() instead.
51
+
52
+ :param ssh_client: An established SshClient connection.
53
+ :param local_port: Local port to bind for forwarding. If 0, an available port will be chosen.
54
+ :param remote_host: Remote host to forward to.
55
+ :param remote_port: Remote port to forward to.
56
+ :param _internal_create: Internal flag to prevent direct instantiation.
57
+ """
58
+ if not _internal_create:
59
+ raise RuntimeError("SshTunnel instances should only be created through SshAgent.open_tunnel() method")
60
+
61
+ self._ssh_client = ssh_client
62
+ self._local_port = local_port
63
+ self._remote_host = remote_host
64
+ self._remote_port = remote_port
65
+ self._actual_local_port = None
66
+ self._local_socket = None
67
+ self._forward_thread = None
68
+ self._active = False
69
+ self._owns_ssh_client = True # This tunnel owns the SSH client and should close it
70
+ self._connection_threads = [] # Track active connection threads
71
+ self._connection_lock = threading.Lock() # Protect connection thread list
72
+
73
+ # Start the tunnel immediately
74
+ self._start_tunnel()
75
+ return
76
+
77
+ @classmethod
78
+ def _create_internal(cls, ssh_client: SshClient, local_port: int, remote_host: str, remote_port: int) -> 'SshTunnel':
79
+ """
80
+ Internal method for creating SshTunnel instances. Only used by SshAgent.
81
+
82
+ :param ssh_client: An established SshClient connection.
83
+ :param local_port: Local port to bind for forwarding. If 0, an available port will be chosen.
84
+ :param remote_host: Remote host to forward to.
85
+ :param remote_port: Remote port to forward to.
86
+
87
+ :returns: A new SshTunnel instance.
88
+ """
89
+ return cls(ssh_client, local_port, remote_host, remote_port, _internal_create=True)
90
+
91
+ def __enter__(self):
92
+ """
93
+ Method that implements the __enter__ semantics used for 'with' statements.
94
+ """
95
+ return self
96
+
97
+ def __exit__(self, ex_type: type, ex_val: Exception, ex_tb: TracebackType) -> bool:
98
+ """
99
+ Method that implements the __exit__ semantics used for 'with' statements.
100
+ Shuts down the tunnel and cleans up all threads and resources.
101
+
102
+ :returns: Returns false indicating that exceptions are not handled.
103
+ """
104
+ self.shutdown()
105
+ handled = False
106
+ return handled
107
+
108
+ def close(self):
109
+ """
110
+ Closes all port forwards and cleans up resources. Also closes the SSH client.
111
+ This is an alias for shutdown() for backward compatibility.
112
+ """
113
+ self.shutdown()
114
+ return
115
+
116
+ def shutdown(self):
117
+ """
118
+ Shuts down the tunnel by stopping the port forward and cleaning up all threads and resources.
119
+ Also closes the SSH client since this tunnel owns it.
120
+ """
121
+ logger.info(f"Shutting down SSH tunnel 127.0.0.1:{self._actual_local_port} -> {self._remote_host}:{self._remote_port}")
122
+
123
+ # Mark as inactive
124
+ self._active = False
125
+
126
+ # Close the listening socket to stop accepting new connections
127
+ if self._local_socket:
128
+ try:
129
+ self._local_socket.close()
130
+ except Exception as e:
131
+ logger.debug(f"Error closing socket: {e}")
132
+ finally:
133
+ self._local_socket = None
134
+
135
+ # Wait for the forwarding thread to complete
136
+ if self._forward_thread and self._forward_thread.is_alive():
137
+ logger.debug("Waiting for forwarding thread to complete...")
138
+ self._forward_thread.join(timeout=10.0)
139
+ if self._forward_thread.is_alive():
140
+ logger.warning("Forwarding thread did not complete within timeout")
141
+
142
+ # Wait for connection handler threads to complete
143
+ with self._connection_lock:
144
+ active_connections = [t for t in self._connection_threads if t.is_alive()]
145
+
146
+ if active_connections:
147
+ logger.debug(f"Waiting for {len(active_connections)} connection handler threads to complete...")
148
+ for thread in active_connections:
149
+ thread.join(timeout=5.0)
150
+ if thread.is_alive():
151
+ logger.warning(f"Connection handler thread did not complete within timeout")
152
+
153
+ # Close the SSH client since this tunnel owns it
154
+ if self._owns_ssh_client and self._ssh_client:
155
+ try:
156
+ self._ssh_client.close()
157
+ logger.debug("SSH client closed")
158
+ except Exception as e:
159
+ logger.warning(f"Error closing SSH client: {e}")
160
+ finally:
161
+ self._ssh_client = None
162
+
163
+ logger.info("SSH tunnel shutdown complete")
164
+ return
165
+
166
+ def _start_tunnel(self):
167
+ """
168
+ Private method that starts the port forwarding tunnel.
169
+ """
170
+ # Create a local socket to listen on
171
+ self._local_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
172
+ self._local_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
173
+
174
+ # If local_port is 0, let the OS choose an available port
175
+ self._local_socket.bind(('127.0.0.1', self._local_port))
176
+ self._actual_local_port = self._local_socket.getsockname()[1]
177
+ self._local_socket.listen(5)
178
+ # Closing a listening socket does not wake a thread blocked in accept() on every platform, so accept with
179
+ # a timeout and let the forwarding loop re-check for shutdown and a dead transport. Accepted connections
180
+ # are still created in blocking mode.
181
+ self._local_socket.settimeout(PUMP_POLL_INTERVAL)
182
+
183
+ # Mark as active
184
+ self._active = True
185
+
186
+ # Start the forwarding loop in a separate daemon thread
187
+ self._forward_thread = threading.Thread(
188
+ target=self._forward_loop,
189
+ daemon=True
190
+ )
191
+ self._forward_thread.start()
192
+
193
+ logger.info(f"SSH tunnel port forward started: 127.0.0.1:{self._actual_local_port} -> {self._remote_host}:{self._remote_port}")
194
+ return
195
+
196
+ def _forward_loop(self):
197
+ """
198
+ Private method that handles the port forwarding loop by accepting connections and forwarding
199
+ traffic over the SSH transport.
200
+ """
201
+ try:
202
+ while self._active:
203
+ try:
204
+ transport = self._ssh_client.get_transport()
205
+ if transport is None or not transport.is_active():
206
+ logger.error("SSH transport is no longer active, stopping tunnel")
207
+ break
208
+
209
+ connection = self._ssh_client.connection
210
+
211
+ # Accept incoming connections
212
+ conn, addr = self._local_socket.accept()
213
+ logger.debug(f"Accepted connection from {addr} for tunnel to {self._remote_host}:{self._remote_port}")
214
+
215
+ # Clean up finished connection threads before creating new ones
216
+ self._cleanup_finished_threads()
217
+
218
+ # Create and track connection handler thread
219
+ conn_thread = threading.Thread(
220
+ target=self._handle_connection,
221
+ args=(conn, connection),
222
+ daemon=True
223
+ )
224
+
225
+ with self._connection_lock:
226
+ self._connection_threads.append(conn_thread)
227
+
228
+ conn_thread.start()
229
+
230
+ except socket.timeout:
231
+ # No connection within the poll interval; loop to re-check for shutdown.
232
+ continue
233
+ except socket.error as e:
234
+ # Socket errors during accept() are expected when shutting down
235
+ if self._active:
236
+ logger.error(f"Socket error in port forward: {e}")
237
+ else:
238
+ logger.debug(f"Socket error during shutdown (expected): {e}")
239
+ break
240
+ except Exception as e:
241
+ logger.error(f"Unexpected error in port forward: {e}")
242
+ break
243
+
244
+ except Exception as e:
245
+ logger.error(f"Port forward thread error: {e}")
246
+ finally:
247
+ try:
248
+ if self._local_socket:
249
+ self._local_socket.close()
250
+ except Exception:
251
+ # Best-effort cleanup at forwarding thread exit; the listener is being discarded and there is
252
+ # no caller to report a close failure to.
253
+ pass
254
+
255
+ def _handle_connection(self, conn: socket.socket, connection: SshConnection):
256
+ """
257
+ Handle a single connection through the tunnel.
258
+
259
+ :param conn: The client connection socket.
260
+ :param connection: The SSH connection to use for forwarding.
261
+ """
262
+ chan = None
263
+ try:
264
+ # RFC 4254 section 7.2: the originator is the local client whose connection is being forwarded.
265
+ chan = connection.open_channel(
266
+ "direct-tcpip",
267
+ dest_addr=(self._remote_host, self._remote_port),
268
+ src_addr=conn.getpeername()
269
+ )
270
+
271
+ self._pump_channel(conn, chan)
272
+ except Exception as e:
273
+ logger.debug(f"Connection handler error: {e}")
274
+ finally:
275
+ try:
276
+ conn.close()
277
+ except Exception:
278
+ # Best-effort cleanup; a failure to close the local socket must not prevent the channel from being closed.
279
+ pass
280
+ if chan is not None:
281
+ try:
282
+ chan.close()
283
+ except Exception:
284
+ # Best-effort cleanup; the forwarded connection is finished and the channel is being discarded.
285
+ pass
286
+
287
+ def _pump_channel(self, conn: socket.socket, chan: SshChannel):
288
+ """
289
+ Forward data between a local socket connection and an SSH channel until one side closes.
290
+
291
+ An :class:`SshChannel` has no file descriptor, so it cannot be passed to ``select``. The socket to
292
+ channel direction runs on this thread and waits on the socket with ``select``; the channel to socket
293
+ direction runs on a companion thread that blocks in :meth:`SshChannel.recv`, which is fed by the
294
+ transport thread. Both directions check for tunnel shutdown every ``PUMP_POLL_INTERVAL`` seconds and
295
+ stop together as soon as either side closes. When the peer only sends EOF (half-close, RFC 4254 section
296
+ 5.3) the local client gets end-of-stream and can keep sending until it closes.
297
+
298
+ :param conn: The local client connection socket.
299
+ :param chan: The open ``direct-tcpip`` channel for the connection.
300
+ """
301
+ pump_done = threading.Event()
302
+
303
+ channel_reader = threading.Thread(
304
+ target=self._pump_channel_to_socket,
305
+ args=(conn, chan, pump_done),
306
+ daemon=True
307
+ )
308
+ channel_reader.start()
309
+
310
+ try:
311
+ self._pump_socket_to_channel(conn, chan, pump_done)
312
+ finally:
313
+ pump_done.set()
314
+ channel_reader.join(timeout=PUMP_JOIN_TIMEOUT)
315
+ if channel_reader.is_alive():
316
+ logger.warning("Channel to socket forwarding thread did not complete within timeout")
317
+
318
+ return
319
+
320
+ def _pump_socket_to_channel(self, conn: socket.socket, chan: SshChannel, pump_done: threading.Event):
321
+ """
322
+ Forward data from the local socket to the SSH channel until the local client closes its side, the
323
+ other direction finishes, or the tunnel shuts down.
324
+
325
+ :param conn: The local client connection socket.
326
+ :param chan: The channel to forward to.
327
+ :param pump_done: Set when either direction has finished.
328
+ """
329
+ while self._active is True and pump_done.is_set() is False and chan.closed is False:
330
+ read_ready, _, _ = select.select([conn], [], [], PUMP_POLL_INTERVAL)
331
+ if conn not in read_ready:
332
+ continue
333
+
334
+ data = conn.recv(PUMP_CHUNK_SIZE)
335
+ if len(data) == 0:
336
+ # The local client closed its side of the connection.
337
+ break
338
+
339
+ chan.send(data)
340
+
341
+ return
342
+
343
+ def _pump_channel_to_socket(self, conn: socket.socket, chan: SshChannel, pump_done: threading.Event):
344
+ """
345
+ Forward data from the SSH channel to the local socket until the peer closes the channel, the other
346
+ direction finishes, or the tunnel shuts down. Runs on its own thread.
347
+
348
+ :param conn: The local client connection socket.
349
+ :param chan: The channel to forward from.
350
+ :param pump_done: Set when either direction has finished; set by this method when it finishes, unless
351
+ the peer only half-closed the channel.
352
+ """
353
+ finish_pump = True
354
+ try:
355
+ while self._active is True and pump_done.is_set() is False:
356
+ data = chan.recv(PUMP_CHUNK_SIZE, timeout=PUMP_POLL_INTERVAL)
357
+ if len(data) > 0:
358
+ conn.sendall(data)
359
+ elif chan.closed is True or chan.eof_received is True:
360
+ # Everything the peer sent has been forwarded; let the local client see end-of-stream now
361
+ # instead of when the socket direction next polls.
362
+ try:
363
+ conn.shutdown(socket.SHUT_WR)
364
+ except OSError as xcpt:
365
+ logger.debug(f"Error shutting down the local socket at the end of the channel data: {xcpt}")
366
+
367
+ if chan.closed is False:
368
+ # Half-close: the peer sends no more data but still receives, so the socket direction
369
+ # keeps forwarding until the local client or the peer closes.
370
+ finish_pump = False
371
+ break
372
+ # Otherwise the receive timed out; loop to re-check for shutdown.
373
+ except OSError as xcpt:
374
+ # The local client went away while data was being written to it.
375
+ logger.debug(f"Local socket error while forwarding channel data: {xcpt}")
376
+ finally:
377
+ if finish_pump is True:
378
+ pump_done.set()
379
+
380
+ return
381
+
382
+ def _cleanup_finished_threads(self):
383
+ """
384
+ Remove finished threads from the connection thread list.
385
+ """
386
+ with self._connection_lock:
387
+ self._connection_threads = [t for t in self._connection_threads if t.is_alive()]
388
+
389
+ @property
390
+ def local_port(self) -> int:
391
+ """
392
+ Returns the actual local port being used for the tunnel.
393
+
394
+ :returns: The local port number.
395
+
396
+ :raises: RuntimeError if the tunnel is not properly initialized.
397
+ """
398
+ if self._actual_local_port is None:
399
+ raise RuntimeError("Tunnel is not properly initialized - no local port assigned")
400
+ return self._actual_local_port
401
+
402
+ @property
403
+ def remote_host(self) -> str:
404
+ """
405
+ Returns the remote host being forwarded to.
406
+
407
+ :returns: The remote host.
408
+ """
409
+ return self._remote_host
410
+
411
+ @property
412
+ def remote_port(self) -> int:
413
+ """
414
+ Returns the remote port being forwarded to.
415
+
416
+ :returns: The remote port number.
417
+ """
418
+ return self._remote_port
419
+
420
+ @property
421
+ def is_active(self) -> bool:
422
+ """
423
+ Returns whether the tunnel is currently active.
424
+
425
+ :returns: True if active, False otherwise.
426
+ """
427
+ return self._active
428
+
429
+ def get_connection_string(self, database: str = None, username: str = None, password: str = None, db_type: str = 'postgresql') -> str:
430
+ """
431
+ Helper method to generate database connection strings for use with SQLAlchemy or other ORMs.
432
+
433
+ :param database: Optional database name.
434
+ :param username: Optional username for the connection string.
435
+ :param password: Optional password for the connection string.
436
+ :param db_type: Database type (postgresql, mysql, etc.).
437
+
438
+ :returns: A connection string template for the tunneled connection.
439
+
440
+ :raises: RuntimeError if the tunnel is not properly initialized.
441
+ """
442
+ local_port = self.local_port # This will raise RuntimeError if not initialized
443
+ base_url = f"127.0.0.1:{local_port}"
444
+
445
+ conn_str: str
446
+
447
+ # Build authentication part
448
+ auth_part = ""
449
+ if username:
450
+ if password:
451
+ auth_part = f"{username}:{password}@"
452
+ else:
453
+ auth_part = f"{username}@"
454
+
455
+ # Build connection string
456
+ if database:
457
+ conn_str = f"{db_type}://{auth_part}{base_url}/{database}"
458
+ elif auth_part or db_type != 'postgresql':
459
+ # Include protocol if we have auth or non-default db_type
460
+ conn_str = f"{db_type}://{auth_part}{base_url}"
461
+ else:
462
+ conn_str = base_url
463
+
464
+ return conn_str
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: codecraftai-protocol-ssh
3
+ Version: 1.3.0
4
+ Summary: Code Craft AI - SSH Protocol
5
+ License-Expression: LicenseRef-CodeCraftAI-Source-Available
6
+ License-File: LICENSE.md
7
+ Keywords: python
8
+ Author: Myron Walker
9
+ Author-email: myron.walker@gmail.com
10
+ Requires-Python: >=3.10,<4.0
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: POSIX
13
+ Requires-Dist: codecraftai-credentials (<2.0.0)
14
+ Requires-Dist: codecraftai-foundations (<2.0.0)
15
+ Requires-Dist: codecraftai-interop (<2.0.0)
16
+ Requires-Dist: codecraftai-xmodules (<2.0.0)
17
+ Requires-Dist: cryptography (>=45.0.5,<51.0.0)
18
+ Project-URL: Issues, https://github.com/code-craft-ai-company/codecraftai-protocol-ssh/issues
19
+ Project-URL: Repository, https://github.com/code-craft-ai-company/codecraftai-protocol-ssh
20
+ Description-Content-Type: text/x-rst
21
+
22
+ ==============================
23
+ Code Craft AI Protocol SSH
24
+ ==============================
25
+
26
+ The ``codecraftai-protocol-ssh`` package is the dedicated SSH-protocol implementation for the ccai suite. It provides the building blocks (transport, session, packet handling, SFTP, tunnels, key and authentication handling, and a client/agent facade) used by any higher-level ccai package that needs to manage remote systems over SSH. It is consumed by ``codecraftai-interop``, ``codecraftai-landscaping`` (SSH-based coordinators), and the sysman packages that reach hosts over SSH.
27
+
28
+ Highlights
29
+ ==========
30
+
31
+ - Connection lifecycle classes: ``SshBase``, ``SshClient``, ``SshConnection``, ``SshSession``, ``SshTransport``, and ``SshAgent``.
32
+ - ``SshAspects`` for packaging behaviour parameters (retry, timeout, action pattern) on SSH calls in the aspects style.
33
+ - Key and authentication helpers in ``sshkeys.py`` and ``sshauth.py``.
34
+ - SFTP and tunnel support in ``sshsftp.py`` and ``sshtunnel.py``.
35
+ - RFC-aligned implementations of the SSH transport and user authentication layers.
36
+
37
+ Repository Structure
38
+ ====================
39
+
40
+ - ``development/`` - Runtime environment scripts.
41
+ - ``repository-setup/`` - Repository homing and setup scripts.
42
+ - ``source/packages/`` - Package source code.
43
+ - ``source/tests/`` - ``unittest``-based tests.
44
+ - ``source/testroots/testbase/`` - TestBase test roots.
45
+ - ``specs/`` - Requirements, design, and reference RFC documents.
46
+ - ``userguide/`` - Standards, conventions, and documentation.
47
+
48
+ Environment Setup
49
+ =================
50
+
51
+ When working in a Codex container:
52
+
53
+ .. code-block:: bash
54
+
55
+ ./development/setup-agent-container
56
+
57
+ For local development:
58
+
59
+ .. code-block:: bash
60
+
61
+ ./repository-setup/rehome-repository
62
+ ./development/setup-envrionment
63
+
64
+ Running Tests
65
+ =============
66
+
67
+ Activate the virtual environment:
68
+
69
+ .. code-block:: bash
70
+
71
+ source .venv/bin/activate
72
+
73
+ Run ``unittest`` tests:
74
+
75
+ .. code-block:: bash
76
+
77
+ python -m unittest discover -s source/tests -p "test_*.py"
78
+
79
+ Run TestBase tests (when test roots are populated):
80
+
81
+ .. code-block:: bash
82
+
83
+ testbase testing run --root source/testroots/testbase --includes=ccai --output=./.output
84
+
85
+ Standards and Conventions
86
+ =========================
87
+
88
+ - Naming conventions: ``userguide/10-02-naming-conventions.rst``
89
+ - Code organization: ``userguide/10-01-code-organization.rst``
90
+ - Coding standards: ``userguide/10-00-coding-standards.rst``
91
+
92
+ Additional Resources
93
+ ====================
94
+
95
+ - Design documentation: ``specs/design.md``
96
+ - Requirements: ``specs/requirements.md``
97
+ - SSH protocol RFCs: ``specs/rfc4251.txt``, ``specs/rfc4252.txt``, ``specs/rfc4254.txt``
98
+
@@ -0,0 +1,20 @@
1
+ ccai/protocols/ssh/__init__.py,sha256=lu-IgDXxPNOP1XbgPcTHUDZRLydfLM_g2SVOZkVbVEk,36
2
+ ccai/protocols/ssh/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ ccai/protocols/ssh/sshagent.py,sha256=b0ibg4zcEB1v_1MfsoLx0YIxoaNialYPGOYfZA7MzKA,16161
4
+ ccai/protocols/ssh/sshauth.py,sha256=v91KY-nzS68mpzaISpmUN83kQJPHdt4ZjIxbMhClky4,4002
5
+ ccai/protocols/ssh/sshbase.py,sha256=srnHnSXbT2WuW51eQQowQj1hvrEh9hMZ8h23xLkBP7E,33054
6
+ ccai/protocols/ssh/sshclient.py,sha256=-RRJzVF7dq176GS3m6B1qeHrVa7THliaNTeKZtEjv_4,6073
7
+ ccai/protocols/ssh/sshconnection.py,sha256=VqlnzvobgiHNCWrRlPA96rjRrpHRsL6sih5K4Bw7H4U,61138
8
+ ccai/protocols/ssh/sshconst.py,sha256=YP-C14z6ZJVfY6K-1MEshsp-GXh6r-W1HoE2hWYWk30,1686
9
+ ccai/protocols/ssh/sshconstants.py,sha256=x69F3VmGNxa_x0epms9OwNjK31dPeJh7mFDaUeO3FeQ,3904
10
+ ccai/protocols/ssh/sshhelpers.py,sha256=FZKZ0OmgsGZv4rOtLsICp7Kbk1na7NkTtm9sEr6rl6g,28592
11
+ ccai/protocols/ssh/sshkeys.py,sha256=Voe-cgVcEauXr8HRUCYI_xfdlFx3Q8xn3YM1v4Szu8A,6036
12
+ ccai/protocols/ssh/sshpacket.py,sha256=vKrIboQ5a5F3EtPoQfHSjoSzgd4fL1cSQ4aaqzabdpI,4231
13
+ ccai/protocols/ssh/sshsession.py,sha256=6fsVPv6FUHJptyMbEtCOGbsmdfpGVWXA9Ao7o8mP5so,20280
14
+ ccai/protocols/ssh/sshsftp.py,sha256=AivLsw8iQTMklsBhW7MSJDdN21fAkwYKvW2g4pHA_n0,10463
15
+ ccai/protocols/ssh/sshtransport.py,sha256=Zycje7iO2Z21NZOyn8wa7KcN_2M7QYHIZmV3St7T9-s,31314
16
+ ccai/protocols/ssh/sshtunnel.py,sha256=40xId1h9yftoFRC-ot_6gud2MJALzZte-pn6G9rxf0E,19278
17
+ codecraftai_protocol_ssh-1.3.0.dist-info/METADATA,sha256=rtsxPI7Op5-T0KbznoF_0z7EReHr4apUFVWFRY252sg,3453
18
+ codecraftai_protocol_ssh-1.3.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
19
+ codecraftai_protocol_ssh-1.3.0.dist-info/licenses/LICENSE.md,sha256=5tyUZ2kpPxNXbULzrdiT14-ZV-jCBeBFL0xyO-zt1mg,17907
20
+ codecraftai_protocol_ssh-1.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any