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,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SFTP Protocol Implementation (Version 3)
|
|
3
|
+
"""
|
|
4
|
+
import struct
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from ccai.protocols.ssh import sshconstants
|
|
8
|
+
from ccai.protocols.ssh.sshpacket import SshPacket
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
class SshSFTPAttributes:
|
|
13
|
+
def __init__(self, size=None, uid=None, gid=None, mode=None, atime=None, mtime=None):
|
|
14
|
+
self.st_size = size
|
|
15
|
+
self.st_uid = uid
|
|
16
|
+
self.st_gid = gid
|
|
17
|
+
self.st_mode = mode
|
|
18
|
+
self.st_atime = atime
|
|
19
|
+
self.st_mtime = mtime
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_attrs(cls, attrs):
|
|
23
|
+
# Helper to create from parsed attributes dict (TODO)
|
|
24
|
+
return cls()
|
|
25
|
+
|
|
26
|
+
class SshSFTPError(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
class SshSFTPClient:
|
|
30
|
+
"""
|
|
31
|
+
SFTP Client implementation.
|
|
32
|
+
"""
|
|
33
|
+
def __init__(self, channel):
|
|
34
|
+
self.channel = channel
|
|
35
|
+
self.request_id = 0
|
|
36
|
+
self.lock = threading.Lock()
|
|
37
|
+
self.channel.request_subsystem("sftp")
|
|
38
|
+
self._init_sftp()
|
|
39
|
+
|
|
40
|
+
def _init_sftp(self):
|
|
41
|
+
"""
|
|
42
|
+
Send INIT and wait for VERSION.
|
|
43
|
+
"""
|
|
44
|
+
pkt = SshPacket()
|
|
45
|
+
pkt.add_uint32(3) # Version 3
|
|
46
|
+
self._send_packet(sshconstants.SSH_FXP_INIT, pkt.payload)
|
|
47
|
+
|
|
48
|
+
msg_id, payload = self._recv_packet()
|
|
49
|
+
if msg_id != sshconstants.SSH_FXP_VERSION:
|
|
50
|
+
raise SshSFTPError(f"Expected SSH_FXP_VERSION, got {msg_id}")
|
|
51
|
+
|
|
52
|
+
# Parse version
|
|
53
|
+
version = struct.unpack('>I', payload[:4])[0]
|
|
54
|
+
logger.info(f"SFTP Version: {version}")
|
|
55
|
+
|
|
56
|
+
def open(self, filename: str, pflags: int = 0, attrs: dict = None) -> bytes:
|
|
57
|
+
"""
|
|
58
|
+
Open a file. Returns handle.
|
|
59
|
+
pflags: SSH_FXF_READ, SSH_FXF_WRITE, etc. (Need mapped constants, assuming valid int passed)
|
|
60
|
+
"""
|
|
61
|
+
req_id = self._next_id()
|
|
62
|
+
pkt = SshPacket()
|
|
63
|
+
pkt.add_uint32(req_id)
|
|
64
|
+
pkt.add_string(filename)
|
|
65
|
+
pkt.add_uint32(0) # Pflags placeholder (TODO: Add mapping) - wait using raw int
|
|
66
|
+
# Actually pflags logic.
|
|
67
|
+
# READ=1, WRITE=2, APPEND=4, CREAT=8, TRUNC=16, EXCL=32
|
|
68
|
+
if pflags == 0:
|
|
69
|
+
pflags = 1 # READ default
|
|
70
|
+
|
|
71
|
+
pkt = SshPacket()
|
|
72
|
+
pkt.add_uint32(req_id)
|
|
73
|
+
pkt.add_string(filename)
|
|
74
|
+
pkt.add_uint32(pflags)
|
|
75
|
+
pkt.add_uint32(0) # Attrs (0)
|
|
76
|
+
|
|
77
|
+
self._send_packet(sshconstants.SSH_FXP_OPEN, pkt.payload)
|
|
78
|
+
msg_id, payload = self._recv_packet()
|
|
79
|
+
return self._parse_handle_response(msg_id, payload, req_id)
|
|
80
|
+
|
|
81
|
+
def close(self, handle: bytes):
|
|
82
|
+
req_id = self._next_id()
|
|
83
|
+
pkt = SshPacket()
|
|
84
|
+
pkt.add_uint32(req_id)
|
|
85
|
+
pkt.add_string(handle)
|
|
86
|
+
self._send_packet(sshconstants.SSH_FXP_CLOSE, pkt.payload)
|
|
87
|
+
msg_id, payload = self._recv_packet()
|
|
88
|
+
self._parse_status_response(msg_id, payload, req_id)
|
|
89
|
+
|
|
90
|
+
def read(self, handle: bytes, offset: int, length: int) -> bytes:
|
|
91
|
+
req_id = self._next_id()
|
|
92
|
+
pkt = SshPacket()
|
|
93
|
+
pkt.add_uint32(req_id)
|
|
94
|
+
pkt.add_string(handle)
|
|
95
|
+
pkt.add_uint64(offset)
|
|
96
|
+
pkt.add_uint32(length)
|
|
97
|
+
|
|
98
|
+
self._send_packet(sshconstants.SSH_FXP_READ, pkt.payload)
|
|
99
|
+
msg_id, payload = self._recv_packet()
|
|
100
|
+
|
|
101
|
+
if msg_id == sshconstants.SSH_FXP_DATA:
|
|
102
|
+
pkt = SshPacket(payload=payload)
|
|
103
|
+
pkt.get_uint32() # Req ID
|
|
104
|
+
return pkt.get_string()
|
|
105
|
+
elif msg_id == sshconstants.SSH_FXP_STATUS:
|
|
106
|
+
self._check_status(payload, req_id)
|
|
107
|
+
return b"" # Should raise EOF if status is EOF
|
|
108
|
+
else:
|
|
109
|
+
raise SshSFTPError(f"Unexpected message {msg_id}")
|
|
110
|
+
|
|
111
|
+
def write(self, handle: bytes, offset: int, data: bytes):
|
|
112
|
+
"""
|
|
113
|
+
Write to a file.
|
|
114
|
+
"""
|
|
115
|
+
req_id = self._next_id()
|
|
116
|
+
pkt = SshPacket()
|
|
117
|
+
pkt.add_uint32(req_id)
|
|
118
|
+
pkt.add_string(handle)
|
|
119
|
+
pkt.add_uint64(offset)
|
|
120
|
+
pkt.add_string(data)
|
|
121
|
+
|
|
122
|
+
self._send_packet(sshconstants.SSH_FXP_WRITE, pkt.payload)
|
|
123
|
+
msg_id, payload = self._recv_packet()
|
|
124
|
+
self._parse_status_response(msg_id, payload, req_id)
|
|
125
|
+
|
|
126
|
+
def listdir(self, path: str) -> list:
|
|
127
|
+
"""
|
|
128
|
+
List directory contents.
|
|
129
|
+
"""
|
|
130
|
+
# Open directory
|
|
131
|
+
req_id = self._next_id()
|
|
132
|
+
pkt = SshPacket()
|
|
133
|
+
pkt.add_uint32(req_id)
|
|
134
|
+
pkt.add_string(path)
|
|
135
|
+
self._send_packet(sshconstants.SSH_FXP_OPENDIR, pkt.payload)
|
|
136
|
+
msg_id, payload = self._recv_packet()
|
|
137
|
+
handle = self._parse_handle_response(msg_id, payload, req_id)
|
|
138
|
+
|
|
139
|
+
items = []
|
|
140
|
+
try:
|
|
141
|
+
while True:
|
|
142
|
+
req_id = self._next_id()
|
|
143
|
+
pkt = SshPacket()
|
|
144
|
+
pkt.add_uint32(req_id)
|
|
145
|
+
pkt.add_string(handle)
|
|
146
|
+
self._send_packet(sshconstants.SSH_FXP_READDIR, pkt.payload)
|
|
147
|
+
|
|
148
|
+
msg_id, payload = self._recv_packet()
|
|
149
|
+
if msg_id == sshconstants.SSH_FXP_NAME:
|
|
150
|
+
pkt = SshPacket(payload=payload)
|
|
151
|
+
rid = pkt.get_uint32()
|
|
152
|
+
if rid != req_id:
|
|
153
|
+
raise SshSFTPError("Request ID mismatch")
|
|
154
|
+
|
|
155
|
+
count = pkt.get_uint32()
|
|
156
|
+
for _ in range(count):
|
|
157
|
+
filename = pkt.get_string().decode('utf-8')
|
|
158
|
+
pkt.get_string() # consume the 'longname' field; only the filename is listed
|
|
159
|
+
self._parse_attrs(pkt) # consume attrs
|
|
160
|
+
items.append(filename)
|
|
161
|
+
elif msg_id == sshconstants.SSH_FXP_STATUS:
|
|
162
|
+
self._check_status(payload, req_id) # Will raise EOF or error
|
|
163
|
+
break
|
|
164
|
+
else:
|
|
165
|
+
raise SshSFTPError(f"Unexpected message {msg_id}")
|
|
166
|
+
except EOFError:
|
|
167
|
+
# SSH_FX_EOF from READDIR marks the end of the directory listing; it is the normal loop exit.
|
|
168
|
+
pass
|
|
169
|
+
finally:
|
|
170
|
+
self.close(handle)
|
|
171
|
+
|
|
172
|
+
return items
|
|
173
|
+
|
|
174
|
+
def stat(self, path: str) -> SshSFTPAttributes:
|
|
175
|
+
"""
|
|
176
|
+
Get attributes for a path (follows symlinks).
|
|
177
|
+
"""
|
|
178
|
+
return self._request_stat(path, sshconstants.SSH_FXP_STAT)
|
|
179
|
+
|
|
180
|
+
def lstat(self, path: str) -> SshSFTPAttributes:
|
|
181
|
+
"""
|
|
182
|
+
Get attributes for a path (does not follow symlinks).
|
|
183
|
+
"""
|
|
184
|
+
return self._request_stat(path, sshconstants.SSH_FXP_LSTAT)
|
|
185
|
+
|
|
186
|
+
def _request_stat(self, path: str, msg_code: int) -> SshSFTPAttributes:
|
|
187
|
+
req_id = self._next_id()
|
|
188
|
+
pkt = SshPacket()
|
|
189
|
+
pkt.add_uint32(req_id)
|
|
190
|
+
pkt.add_string(path)
|
|
191
|
+
self._send_packet(msg_code, pkt.payload)
|
|
192
|
+
|
|
193
|
+
msg_id, payload = self._recv_packet()
|
|
194
|
+
if msg_id == sshconstants.SSH_FXP_ATTRS:
|
|
195
|
+
pkt = SshPacket(payload=payload)
|
|
196
|
+
rid = pkt.get_uint32()
|
|
197
|
+
if rid != req_id:
|
|
198
|
+
raise SshSFTPError("Request ID mismatch")
|
|
199
|
+
return self._parse_attrs(pkt)
|
|
200
|
+
elif msg_id == sshconstants.SSH_FXP_STATUS:
|
|
201
|
+
self._check_status(payload, req_id)
|
|
202
|
+
# Should raise error
|
|
203
|
+
return None
|
|
204
|
+
else:
|
|
205
|
+
raise SshSFTPError(f"Unexpected message {msg_id}")
|
|
206
|
+
|
|
207
|
+
def _parse_attrs(self, pkt: SshPacket) -> SshSFTPAttributes:
|
|
208
|
+
"""
|
|
209
|
+
Parse SFTP Attributes from packet.
|
|
210
|
+
"""
|
|
211
|
+
flags = pkt.get_uint32()
|
|
212
|
+
size = None
|
|
213
|
+
uid = None
|
|
214
|
+
gid = None
|
|
215
|
+
mode = None
|
|
216
|
+
atime = None
|
|
217
|
+
mtime = None
|
|
218
|
+
|
|
219
|
+
if flags & sshconstants.SSH_FILEXFER_ATTR_SIZE:
|
|
220
|
+
size = pkt.get_uint64()
|
|
221
|
+
if flags & sshconstants.SSH_FILEXFER_ATTR_UIDGID:
|
|
222
|
+
uid = pkt.get_uint32()
|
|
223
|
+
gid = pkt.get_uint32()
|
|
224
|
+
if flags & sshconstants.SSH_FILEXFER_ATTR_PERMISSIONS:
|
|
225
|
+
mode = pkt.get_uint32()
|
|
226
|
+
if flags & sshconstants.SSH_FILEXFER_ATTR_ACMODTIME:
|
|
227
|
+
atime = pkt.get_uint32()
|
|
228
|
+
mtime = pkt.get_uint32()
|
|
229
|
+
if flags & sshconstants.SSH_FILEXFER_ATTR_EXTENDED:
|
|
230
|
+
count = pkt.get_uint32()
|
|
231
|
+
for _ in range(count):
|
|
232
|
+
pkt.get_string()
|
|
233
|
+
pkt.get_string()
|
|
234
|
+
|
|
235
|
+
return SshSFTPAttributes(size, uid, gid, mode, atime, mtime)
|
|
236
|
+
|
|
237
|
+
def _next_id(self):
|
|
238
|
+
with self.lock:
|
|
239
|
+
self.request_id += 1
|
|
240
|
+
return self.request_id
|
|
241
|
+
|
|
242
|
+
def _send_packet(self, msg_type: int, payload: bytes):
|
|
243
|
+
# Frame: Length (4) + Type (1) + Payload
|
|
244
|
+
length = 1 + len(payload)
|
|
245
|
+
out = struct.pack('>I', length) + bytes([msg_type]) + payload
|
|
246
|
+
self.channel.send(out)
|
|
247
|
+
|
|
248
|
+
def _recv_packet(self):
|
|
249
|
+
# Read length
|
|
250
|
+
data = self._read_exactly(4)
|
|
251
|
+
length = struct.unpack('>I', data)[0]
|
|
252
|
+
|
|
253
|
+
# Read type + payload
|
|
254
|
+
data = self._read_exactly(length)
|
|
255
|
+
msg_type = data[0]
|
|
256
|
+
payload = data[1:]
|
|
257
|
+
return msg_type, payload
|
|
258
|
+
|
|
259
|
+
def _read_exactly(self, n):
|
|
260
|
+
data = b""
|
|
261
|
+
while len(data) < n:
|
|
262
|
+
chunk = self.channel.recv(n - len(data))
|
|
263
|
+
if not chunk:
|
|
264
|
+
raise SshSFTPError("Connection closed")
|
|
265
|
+
data += chunk
|
|
266
|
+
return data
|
|
267
|
+
|
|
268
|
+
def _parse_handle_response(self, msg_id, payload, req_id):
|
|
269
|
+
if msg_id == sshconstants.SSH_FXP_HANDLE:
|
|
270
|
+
pkt = SshPacket(payload=payload)
|
|
271
|
+
rid = pkt.get_uint32()
|
|
272
|
+
if rid != req_id:
|
|
273
|
+
raise SshSFTPError("Request ID mismatch")
|
|
274
|
+
return pkt.get_string()
|
|
275
|
+
elif msg_id == sshconstants.SSH_FXP_STATUS:
|
|
276
|
+
self._check_status(payload, req_id)
|
|
277
|
+
# A non-OK status raises above; an OK status carries no handle.
|
|
278
|
+
return None
|
|
279
|
+
else:
|
|
280
|
+
raise SshSFTPError(f"Unexpected message {msg_id}")
|
|
281
|
+
|
|
282
|
+
def _parse_status_response(self, msg_id, payload, req_id):
|
|
283
|
+
if msg_id == sshconstants.SSH_FXP_STATUS:
|
|
284
|
+
self._check_status(payload, req_id)
|
|
285
|
+
else:
|
|
286
|
+
raise SshSFTPError(f"Unexpected message {msg_id}")
|
|
287
|
+
|
|
288
|
+
def _check_status(self, payload, req_id):
|
|
289
|
+
pkt = SshPacket(payload=payload)
|
|
290
|
+
rid = pkt.get_uint32()
|
|
291
|
+
status_code = pkt.get_uint32()
|
|
292
|
+
if rid != req_id:
|
|
293
|
+
raise SshSFTPError("Request ID mismatch in status")
|
|
294
|
+
if status_code != sshconstants.SSH_FX_OK:
|
|
295
|
+
if status_code == sshconstants.SSH_FX_EOF:
|
|
296
|
+
raise EOFError("SFTP EOF")
|
|
297
|
+
raise SshSFTPError(f"SFTP Error Code: {status_code}")
|