ccssh 1.0.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.
ccssh/mux.py ADDED
@@ -0,0 +1,301 @@
1
+ """
2
+ Channel multiplexer: share one SSH transport between exec/sftp/shell
3
+ channels and port forwarders.
4
+
5
+ SSH has a single transport stream, so exactly one thread may read it.
6
+ Without a multiplexer, two concurrent readers (e.g. an exec loop and a
7
+ forwarding pump) steal each other's packets and both stall. The mux owns
8
+ the only reader thread; everyone else consumes per-channel queues:
9
+
10
+ * channel messages carrying our channel id go to that channel's queue;
11
+ * ``CHANNEL_SUCCESS``/``CHANNEL_FAILURE`` (99/100, which carry NO channel
12
+ id on the wire) go to the channel with the oldest outstanding channel
13
+ request (see :meth:`expect_reply` - register *before* sending);
14
+ * server-initiated ``CHANNEL_OPEN`` and stray 81/82 go to the catch-all
15
+ queue (port forwarders);
16
+ * ``DISCONNECT``/``KEXINIT`` are broadcast to every queue (in-flight
17
+ operations keep their existing "peer rekey/closed" error paths);
18
+ * ``IGNORE``/``DEBUG`` are skipped, ``GLOBAL_REQUEST`` answered centrally.
19
+
20
+ Rekey still needs idle channels (same rule as before): :meth:`busy`
21
+ reports whether any channel is registered. The reader also emits idle
22
+ SSH ``IGNORE`` keepalives so quiet forward-only connections survive.
23
+ """
24
+ from __future__ import annotations
25
+ import queue
26
+ import struct
27
+ import threading
28
+ import time
29
+
30
+ from ccssh.transport import CCSSHError, CCSSHTimeoutError, enc_ignore
31
+
32
+ _DEAD = object() # sentinel: connection died, wake every blocked getter
33
+
34
+
35
+ class ChannelMux:
36
+ """Demultiplex one transport into per-channel queues (sync only)."""
37
+
38
+ def __init__(self, transport, *, idle_keepalive: float = 30.0):
39
+ self._t = transport
40
+ self._idle_keepalive = idle_keepalive
41
+ self._lock = threading.Lock()
42
+ self._queues: dict[int, queue.Queue] = {}
43
+ self._catch: queue.Queue = queue.Queue()
44
+ self._expect: list[int] = [] # channels awaiting 99/100, oldest first
45
+ self._stop = threading.Event()
46
+ self._dead = threading.Event()
47
+ self._dead_err: Exception | None = None
48
+ self._paused = threading.Event()
49
+ self._parked = threading.Event()
50
+ # Locks: _lock guards maps/queues; request_lock serialises
51
+ # expect_reply()+send pairs so 99/100 replies cannot be misrouted
52
+ # when two threads set up channels concurrently.
53
+ self.request_lock = threading.Lock()
54
+ self._chan_next = 1
55
+ self._last_io = time.monotonic()
56
+ self._th = threading.Thread(target=self._reader, daemon=True)
57
+
58
+ # lifecycle
59
+ def start(self) -> "ChannelMux":
60
+ self._th.start()
61
+ return self
62
+
63
+ def stop(self) -> None:
64
+ self._stop.set()
65
+ self._paused.clear() # let a parked reader observe the stop
66
+ self._die(CCSSHError("multiplexer stopped"))
67
+ th = self._th
68
+ if th.is_alive():
69
+ th.join(timeout=2.0)
70
+
71
+ def pause(self) -> None:
72
+ """Park the reader outside any transport read (for rekey).
73
+
74
+ Rekey swaps the transport's crypto state mid-stream; a concurrent
75
+ reader would decrypt one half of a packet with the old keys and
76
+ the other half with the new ones. Blocks (bounded) until parked.
77
+ No-op when the reader isn't running yet.
78
+ """
79
+ self._paused.set()
80
+ if not self._th.is_alive():
81
+ # Reader not running (e.g. pause-before-start): just latch the
82
+ # flag; the first loop iteration will park on it.
83
+ return
84
+ try:
85
+ margin = self.timeout
86
+ except Exception:
87
+ margin = 10.0
88
+ deadline = time.monotonic() + max(2.0, margin + 1.0)
89
+ while not self._parked.is_set():
90
+ if self._dead.is_set() or self._stop.is_set():
91
+ break
92
+ if time.monotonic() > deadline:
93
+ raise CCSSHError("timed out parking the mux reader")
94
+ time.sleep(0.01)
95
+
96
+ def resume(self) -> None:
97
+ self._paused.clear()
98
+ # The reader may be inside its park-wait: give it a beat to
99
+ # observe the cleared flag and leave the parked state, otherwise
100
+ # a stale parked bit would confuse the next pause().
101
+ deadline = time.monotonic() + 2.0
102
+ while self._parked.is_set() and time.monotonic() < deadline:
103
+ time.sleep(0.01)
104
+ self._parked.clear()
105
+
106
+ def busy(self) -> bool:
107
+ """
108
+ Any registered channel queues (open or pending channels).
109
+
110
+ A forwarder with zero open channels does not count: its pump only
111
+ consumes mux queues and idle-sends nothing that a rekey could
112
+ corrupt (rekey pauses the reader; sends are atomic per packet).
113
+ """
114
+ with self._lock:
115
+ return bool(self._queues)
116
+
117
+ @property
118
+ def dead(self) -> bool:
119
+ return self._dead.is_set()
120
+
121
+ # channels
122
+ def register(self, chan_id: int) -> queue.Queue:
123
+ """Create (or reuse) the inbound queue for our channel id."""
124
+ with self._lock:
125
+ q = self._queues.get(chan_id)
126
+ if q is None:
127
+ q = queue.Queue()
128
+ self._queues[chan_id] = q
129
+ return q
130
+
131
+ def alloc_chan(self) -> int:
132
+ """
133
+ Allocate a fresh channel id from the single shared namespace.
134
+
135
+ All mux users (exec/shell/sftp/tunnels/forwards) must allocate
136
+ here: disjoint local counters would eventually collide on the
137
+ shared queues and misroute packets (channel-confusion).
138
+ """
139
+ with self._lock:
140
+ n = self._chan_next
141
+ self._chan_next = n + 1
142
+ return n
143
+
144
+ def _queue_for(self, chan_id: int) -> queue.Queue | None:
145
+ """Queue lookup without creating (for drain loops)."""
146
+ with self._lock:
147
+ return self._queues.get(chan_id)
148
+
149
+ def unregister(self, chan_id: int) -> None:
150
+ with self._lock:
151
+ self._queues.pop(chan_id, None)
152
+ self._expect = [c for c in self._expect if c != chan_id]
153
+
154
+ def expect_reply(self, chan_id: int) -> None:
155
+ """
156
+ Record an outstanding channel request (call BEFORE sending it,
157
+ so a fast reply can never overtake the registration).
158
+ """
159
+ with self._lock:
160
+ self._expect.append(chan_id)
161
+
162
+ def catch_all(self) -> queue.Queue:
163
+ return self._catch
164
+
165
+ def send(self, payload: bytes) -> None:
166
+ self._t.send_packet(payload)
167
+
168
+ @property
169
+ def timeout(self) -> float:
170
+ try:
171
+ return float(getattr(self._t, "timeout", 10.0))
172
+ except Exception:
173
+ return 10.0
174
+
175
+ # consumption
176
+ def _raise_if_dead(self) -> None:
177
+ if self._dead.is_set():
178
+ raise CCSSHError(str(self._dead_err or "connection closed by peer"))
179
+
180
+ def recv_chan(self, chan_id: int, timeout: float | None = None) -> bytes:
181
+ """Next packet for a channel (None timeout blocks until it arrives
182
+ or the connection dies; a numeric timeout raises
183
+ CCSSHTimeoutError like a direct transport read)."""
184
+ with self._lock:
185
+ q = self._queues.get(chan_id)
186
+ if q is None:
187
+ raise CCSSHError(f"channel {chan_id} is not registered with the mux")
188
+ while True:
189
+ try:
190
+ p = q.get(timeout=timeout)
191
+ except queue.Empty:
192
+ self._raise_if_dead()
193
+ raise CCSSHTimeoutError("channel read timeout")
194
+ if p is _DEAD:
195
+ # Re-queue for any other getter on this channel, then fail.
196
+ try:
197
+ q.put(_DEAD)
198
+ except Exception:
199
+ pass
200
+ self._raise_if_dead()
201
+ raise CCSSHError("connection closed by peer")
202
+ return p
203
+
204
+ # the only transport reader
205
+ def _die(self, err: Exception) -> None:
206
+ with self._lock:
207
+ if self._dead.is_set():
208
+ return
209
+ self._dead_err = err
210
+ self._dead.set()
211
+ targets = list(self._queues.values()) + [self._catch]
212
+ for q in targets:
213
+ try:
214
+ q.put(_DEAD)
215
+ except Exception:
216
+ pass
217
+
218
+ def _broadcast(self, p: bytes) -> None:
219
+ with self._lock:
220
+ targets = list(self._queues.values()) + [self._catch]
221
+ for q in targets:
222
+ try:
223
+ q.put(p)
224
+ except Exception:
225
+ pass
226
+
227
+ def _route(self, p: bytes) -> None:
228
+ # NB: IGNORE/DEBUG/GLOBAL_REQUEST never reach here: the reader uses
229
+ # transport.recv_packet(), which skips and answers them centrally.
230
+ if not p:
231
+ return
232
+ m = p[0]
233
+ if m in (1, 20): # DISCONNECT, KEXINIT: everyone must see these
234
+ self._broadcast(p)
235
+ if m == 1:
236
+ self._die(CCSSHError("connection closed by peer"))
237
+ return
238
+ if m in (99, 100): # SUCCESS/FAILURE carry no channel id
239
+ with self._lock:
240
+ chan = self._expect.pop(0) if self._expect else None
241
+ q = self._queues.get(chan) if chan is not None else None
242
+ if q is not None:
243
+ try:
244
+ q.put(p)
245
+ except Exception:
246
+ pass
247
+ return
248
+ if m == 90:
249
+ # Server-initiated open (forwarded-tcpip): p[1:5] is the type
250
+ # string length, NOT a channel id - always to catch-all.
251
+ try:
252
+ self._catch.put(p)
253
+ except Exception:
254
+ pass
255
+ return
256
+ if len(p) >= 5 and m in (91, 92, 93, 94, 95, 96, 97, 98):
257
+ try:
258
+ (rcpt,) = struct.unpack(">I", p[1:5])
259
+ except Exception:
260
+ return
261
+ with self._lock:
262
+ q = self._queues.get(rcpt)
263
+ if q is not None:
264
+ try:
265
+ q.put(p)
266
+ except Exception:
267
+ pass
268
+ # anything else for an unknown/stale channel is dropped
269
+ return
270
+ try:
271
+ self._catch.put(p)
272
+ except Exception:
273
+ pass
274
+
275
+ def _reader(self) -> None:
276
+ while not self._stop.is_set():
277
+ if self._paused.is_set():
278
+ self._parked.set()
279
+ self._paused.wait(0.5)
280
+ continue
281
+ self._parked.clear()
282
+ try:
283
+ p = self._t.recv_packet(keepalive=False)
284
+ except CCSSHTimeoutError:
285
+ if self._idle_keepalive > 0 and not self._stop.is_set() \
286
+ and time.monotonic() - self._last_io >= self._idle_keepalive:
287
+ try:
288
+ self.send(enc_ignore())
289
+ except Exception:
290
+ self._die(CCSSHError("connection closed by peer"))
291
+ return
292
+ self._last_io = time.monotonic()
293
+ continue
294
+ except Exception as e:
295
+ self._die(e)
296
+ return
297
+ self._last_io = time.monotonic()
298
+ try:
299
+ self._route(p)
300
+ except Exception:
301
+ pass
ccssh/sftp.py ADDED
@@ -0,0 +1,270 @@
1
+ """
2
+ SFTP version 3 client (draft-ietf-secsh-filexfer-02): stat, list, get, put,
3
+ remove, mkdir over a subsystem channel.
4
+
5
+ SFTP packets may straddle SSH channel messages, so responses accumulate in
6
+ a length-prefixed buffer. Each request goes out as a single CHANNEL_DATA
7
+ message within the server's maxpacket (servers drop oversized ones).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from ccssh import wire as W
12
+
13
+ # Message types.
14
+ F_INIT, F_VERSION = 1, 2
15
+ F_OPEN, F_CLOSE, F_READ, F_WRITE = 3, 4, 5, 6
16
+ F_OPENDIR, F_READDIR = 11, 12
17
+ F_REMOVE, F_MKDIR = 13, 14
18
+ F_REALPATH, F_STAT = 16, 17
19
+ F_HANDLE, F_DATA, F_NAME, F_ATTRS, F_STATUS = 102, 103, 104, 105, 101
20
+
21
+ # Status codes.
22
+ S_OK, S_EOF = 0, 1
23
+ S_NO_SUCH_FILE, S_FAILURE = 2, 4
24
+ S_OP_UNSUPPORTED = 8
25
+
26
+ # OPEN pflags.
27
+ P_READ, P_WRITE, P_APPEND, P_CREAT, P_TRUNC, P_EXCL = 0x01, 0x02, 0x04, 0x08, 0x10, 0x20
28
+
29
+ # Attribute flags.
30
+ A_SIZE, A_PERM, A_ACMOD = 0x01, 0x04, 0x08
31
+ A_EXTENDED = 0x80000000
32
+
33
+ CHUNK = 32768
34
+
35
+
36
+ class SFTPError(ValueError):
37
+ pass
38
+
39
+
40
+ def _req(t: int, rid: int, body: bytes) -> bytes:
41
+ return W.enc_u32(1 + 4 + len(body)) + bytes([t]) + W.enc_u32(rid) + body
42
+
43
+
44
+ def _dec_attrs_reader(r: W.Reader) -> dict:
45
+ fl = r.u32()
46
+ out: dict = {}
47
+ if fl & A_SIZE:
48
+ out["size"] = r.u64()
49
+ if fl & 0x02:
50
+ out["uid"], out["gid"] = r.u32(), r.u32()
51
+ if fl & A_PERM:
52
+ out["permissions"] = r.u32()
53
+ if fl & A_ACMOD:
54
+ out["atime"], out["mtime"] = r.u32(), r.u32()
55
+ if fl & A_EXTENDED:
56
+ n = r.u32()
57
+ if n > 64:
58
+ raise SFTPError("too many extended attrs")
59
+ for _ in range(n):
60
+ r.string()
61
+ r.string()
62
+ return out
63
+
64
+
65
+ def dec_attrs(buf: bytes) -> dict:
66
+ return _dec_attrs_reader(W.Reader(buf))
67
+
68
+
69
+ class SFTPClient:
70
+ """An open SFTP channel. Use CCSSHClient.open_sftp()."""
71
+
72
+ """
73
+ Upper bound on one SFTP reply (length prefix). Our own requests are
74
+ small (reads are <= maxpacket); anything bigger is a malicious or
75
+ broken server - fail instead of buffering it into memory.
76
+ """
77
+ MAX_REPLY = 1024 * 1024
78
+
79
+ def __init__(self, send_chan_data, recv_packet_fn, server_chan: int, on_close=None, maxpacket: int = 32768):
80
+ self._send = send_chan_data # fn(server_chan, bytes)
81
+ self._recv = recv_packet_fn # fn() -> channel-data bytes (one message)
82
+ self._srv = server_chan
83
+ self._on_close = on_close
84
+ """
85
+ Servers drop CHANNEL_DATA beyond their maxpacket ("rcvd big packet"),
86
+ so every request goes out as one message within maxpacket.
87
+ """
88
+ self._maxpacket = max(1024, maxpacket)
89
+ self._rid = 1
90
+ self._buf = b""
91
+ self._closed = False
92
+
93
+ def close(self) -> None:
94
+ if self._closed:
95
+ return
96
+ self._closed = True
97
+ if self._on_close:
98
+ self._on_close()
99
+
100
+ def _call(self, ftype: int, body: bytes) -> tuple[int, bytes]:
101
+ rid = self._rid & 0xFFFFFFFF
102
+ if rid == 0: # 0 is never used on the wire: skip it explicitly
103
+ rid = 1
104
+ self._rid = 2
105
+ else:
106
+ self._rid += 1
107
+ req = _req(ftype, rid, body)
108
+ if len(req) > self._maxpacket:
109
+ raise SFTPError("sftp request exceeds server maxpacket")
110
+ self._send(self._srv, req)
111
+ while True:
112
+ data = self._recv()
113
+ self._buf += data
114
+ if len(self._buf) > 2 * self.MAX_REPLY + 4:
115
+ # Pipelined/garbage bytes beyond any single reply: fail
116
+ # closed instead of buffering a runaway server into OOM.
117
+ try:
118
+ self.close()
119
+ except Exception:
120
+ pass
121
+ raise SFTPError("sftp: reply buffer exceeded")
122
+ if len(self._buf) < 4:
123
+ continue
124
+ ln = int.from_bytes(self._buf[:4], "big")
125
+ if ln > self.MAX_REPLY:
126
+ raise SFTPError(f"sftp: reply too big ({ln} bytes)")
127
+ if len(self._buf) < 4 + ln:
128
+ continue
129
+ pkt, self._buf = self._buf[4:4 + ln], self._buf[4 + ln:]
130
+ r = W.Reader(pkt)
131
+ rt, rr = r.byte(), r.u32()
132
+ if rr != rid:
133
+ # Desynchronised stream: fail closed, do not continue on a
134
+ # half-consumed buffer.
135
+ try:
136
+ self.close()
137
+ except Exception:
138
+ pass
139
+ raise SFTPError(f"sftp: id mismatch {rr}!={rid}")
140
+ return rt, pkt[5:]
141
+
142
+ def _check(self, rtype: int, rest: bytes, want: int) -> bytes:
143
+ if rtype == F_STATUS:
144
+ r = W.Reader(rest)
145
+ code = r.u32()
146
+ msg = r.string().decode(errors="replace")
147
+ if code != S_OK:
148
+ raise SFTPError(f"sftp status {code}: {msg}")
149
+ return b""
150
+ if rtype != want:
151
+ raise SFTPError(f"sftp: expected {want}, got {rtype}")
152
+ return rest
153
+
154
+ def realpath(self, path: str) -> str:
155
+ t, rest = self._call(F_REALPATH, W.enc_string(path.encode()))
156
+ r = W.Reader(self._check(t, rest, F_NAME))
157
+ r.u32() # count, always 1 here
158
+ return r.string().decode()
159
+
160
+ def stat(self, path: str) -> dict:
161
+ t, rest = self._call(F_STAT, W.enc_string(path.encode()))
162
+ return dec_attrs(self._check(t, rest, F_ATTRS))
163
+
164
+ def listdir(self, path: str, *, max_entries: int = 50000) -> list[str]:
165
+ t, rest = self._call(F_OPENDIR, W.enc_string(path.encode()))
166
+ handle = W.Reader(self._check(t, rest, F_HANDLE)).string()
167
+ out: list[str] = []
168
+ out_bytes = 0
169
+ try:
170
+ while True:
171
+ t2, r2 = self._call(F_READDIR, W.enc_string(handle))
172
+ if t2 == F_STATUS:
173
+ r = W.Reader(r2)
174
+ if r.u32() == S_EOF:
175
+ break
176
+ raise SFTPError("readdir failed")
177
+ r = W.Reader(self._check(t2, r2, F_NAME))
178
+ n = r.u32()
179
+ if n > 1024:
180
+ raise SFTPError("readdir count too large")
181
+ for _ in range(n):
182
+ name = r.string().decode()
183
+ r.string() # longname
184
+ _dec_attrs_reader(r)
185
+ out_bytes += len(name.encode())
186
+ if len(out) >= max_entries or out_bytes > 4 * 1024 * 1024:
187
+ raise SFTPError("directory listing too large")
188
+ out.append(name)
189
+ finally:
190
+ try:
191
+ self._call(F_CLOSE, W.enc_string(handle))
192
+ except Exception:
193
+ pass # never mask the original error with a close failure
194
+ return out
195
+
196
+ def _open(self, path: str, pflags: int) -> bytes:
197
+ t, rest = self._call(F_OPEN, W.enc_string(path.encode()) + W.enc_u32(pflags) + W.enc_u32(0))
198
+ return W.Reader(self._check(t, rest, F_HANDLE)).string()
199
+
200
+ def _close(self, handle: bytes) -> None:
201
+ t, rest = self._call(F_CLOSE, W.enc_string(handle))
202
+ if t == F_STATUS:
203
+ r = W.Reader(rest)
204
+ if r.u32() != S_OK:
205
+ raise SFTPError("close failed")
206
+
207
+ def get(self, path: str, *, max_bytes: int | None = 1 << 30) -> bytes:
208
+ """
209
+ Download a whole file. A server error mid-transfer raises
210
+ (never silent truncation); max_bytes caps a runaway server
211
+ (None disables the cap).
212
+ """
213
+ h = self._open(path, P_READ)
214
+ chunks: list[bytes] = []
215
+ off = 0
216
+ try:
217
+ while True:
218
+ t, rest = self._call(F_READ, W.enc_string(h) + W.enc_u64(off) + W.enc_u32(CHUNK))
219
+ if t == F_STATUS:
220
+ r = W.Reader(rest)
221
+ if r.u32() == S_EOF:
222
+ break
223
+ raise SFTPError(f"read failed: {r.string().decode(errors='replace')}")
224
+ data = W.Reader(self._check(t, rest, F_DATA)).string()
225
+ if not data:
226
+ break
227
+ chunks.append(data)
228
+ off += len(data)
229
+ if max_bytes is not None and off > max_bytes:
230
+ raise SFTPError(f"file exceeds max_bytes={max_bytes}")
231
+ finally:
232
+ try:
233
+ self._close(h)
234
+ except Exception:
235
+ pass # never mask the transfer error with a close failure
236
+ return b"".join(chunks)
237
+
238
+ def put(self, path: str, data: bytes, *, mode: int = 0o644) -> None:
239
+ attrs = W.enc_u32(A_PERM) + W.enc_u32(mode)
240
+ t, rest = self._call(F_OPEN, W.enc_string(path.encode()) + W.enc_u32(P_WRITE | P_CREAT | P_TRUNC) + attrs)
241
+ h = W.Reader(self._check(t, rest, F_HANDLE)).string()
242
+ if not data:
243
+ # Empty file: OPEN|TRUNC already truncated it; no zero-WRITE.
244
+ self._close(h)
245
+ return
246
+ wchunk = max(1024, self._maxpacket - 128)
247
+ try:
248
+ off = 0
249
+ while off < len(data):
250
+ chunk = data[off:off + wchunk]
251
+ t2, r2 = self._call(F_WRITE, W.enc_string(h) + W.enc_u64(off) + W.enc_string(chunk))
252
+ if t2 == F_STATUS:
253
+ r = W.Reader(r2)
254
+ if r.u32() != S_OK:
255
+ raise SFTPError("write failed")
256
+ off += len(chunk)
257
+ finally:
258
+ try:
259
+ self._close(h)
260
+ except Exception:
261
+ pass # never mask the transfer error with a close failure
262
+
263
+ def remove(self, path: str) -> None:
264
+ t, rest = self._call(F_REMOVE, W.enc_string(path.encode()))
265
+ self._check(t, rest, F_STATUS)
266
+
267
+ def mkdir(self, path: str, *, mode: int = 0o755) -> None:
268
+ attrs = W.enc_u32(A_PERM) + W.enc_u32(mode)
269
+ t, rest = self._call(F_MKDIR, W.enc_string(path.encode()) + attrs)
270
+ self._check(t, rest, F_STATUS)