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/__init__.py +21 -0
- ccssh/__main__.py +5 -0
- ccssh/algs.py +290 -0
- ccssh/async_client.py +419 -0
- ccssh/auth.py +170 -0
- ccssh/certs.py +283 -0
- ccssh/chacha.py +117 -0
- ccssh/channel.py +124 -0
- ccssh/chansock.py +227 -0
- ccssh/cli.py +889 -0
- ccssh/client.py +1274 -0
- ccssh/crypto.py +316 -0
- ccssh/dh_groups.py +92 -0
- ccssh/forward.py +872 -0
- ccssh/gcm.py +87 -0
- ccssh/hostcheck.py +217 -0
- ccssh/hostkeys.py +250 -0
- ccssh/kex.py +222 -0
- ccssh/kex_ext.py +429 -0
- ccssh/mldsa.py +210 -0
- ccssh/mlkem.py +367 -0
- ccssh/mux.py +301 -0
- ccssh/sftp.py +270 -0
- ccssh/shell.py +257 -0
- ccssh/sk.py +224 -0
- ccssh/sntrup.py +325 -0
- ccssh/stub.py +710 -0
- ccssh/transport.py +737 -0
- ccssh/umac.py +262 -0
- ccssh/wire.py +199 -0
- ccssh-1.0.0.dist-info/METADATA +442 -0
- ccssh-1.0.0.dist-info/RECORD +36 -0
- ccssh-1.0.0.dist-info/WHEEL +5 -0
- ccssh-1.0.0.dist-info/entry_points.txt +2 -0
- ccssh-1.0.0.dist-info/licenses/LICENSE +21 -0
- ccssh-1.0.0.dist-info/top_level.txt +1 -0
ccssh/forward.py
ADDED
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local (-L) and remote (-R) TCP forwarding over SSH channels.
|
|
3
|
+
|
|
4
|
+
The forwarders share the connection through the channel multiplexer
|
|
5
|
+
(:class:`ccssh.mux.ChannelMux`), ssh-like: exec/sftp/shell may run on
|
|
6
|
+
the same connection while forwarding is active. Each forwarder runs one
|
|
7
|
+
pump thread that multiplexes local sockets and its mux queues; the mux
|
|
8
|
+
owns the only transport reader, so concurrent channels never interleave
|
|
9
|
+
the framing (the pre-mux single-reader deadlock is gone by construction:
|
|
10
|
+
no lock is ever held across a blocking receive).
|
|
11
|
+
|
|
12
|
+
Orderly shutdown is a half-close: local EOF sends ``CHANNEL_EOF`` and
|
|
13
|
+
keeps relaying server->local until the server closes (no data loss).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
import queue
|
|
17
|
+
import select
|
|
18
|
+
import socket
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
from ccssh import channel as CH
|
|
23
|
+
from ccssh import wire as W
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ForwardError(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _listen(bind: str, lport: int) -> socket.socket:
|
|
31
|
+
"""Listener socket for v4/v6 hostnames and literal addresses."""
|
|
32
|
+
last: OSError | None = None
|
|
33
|
+
try:
|
|
34
|
+
infos = socket.getaddrinfo(bind or "127.0.0.1", lport, type=socket.SOCK_STREAM)
|
|
35
|
+
except OSError:
|
|
36
|
+
infos = []
|
|
37
|
+
for af, st, proto, _canon, sa in infos or [(socket.AF_INET, socket.SOCK_STREAM, 0, "", (bind, lport))]:
|
|
38
|
+
s: socket.socket | None = None
|
|
39
|
+
try:
|
|
40
|
+
s = socket.socket(af, st, proto)
|
|
41
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
42
|
+
try:
|
|
43
|
+
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
s.bind(sa)
|
|
47
|
+
s.listen(16)
|
|
48
|
+
return s
|
|
49
|
+
except OSError as e:
|
|
50
|
+
last = e
|
|
51
|
+
if s is not None:
|
|
52
|
+
try:
|
|
53
|
+
s.close()
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
continue
|
|
57
|
+
raise ForwardError(f"listen {bind}:{lport} failed: {last}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _PendingOpen:
|
|
61
|
+
__slots__ = ("event", "srv", "window", "maxpacket", "failure")
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self.event = threading.Event()
|
|
65
|
+
self.srv: int | None = None
|
|
66
|
+
self.window = 0
|
|
67
|
+
self.maxpacket = 0
|
|
68
|
+
self.failure = False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class BaseForwarder:
|
|
72
|
+
"""Shared pump/pending-open machinery for -L and -R (mux-fed)."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, mux) -> None:
|
|
75
|
+
self._mux = mux
|
|
76
|
+
# Lock order is ALWAYS map_lock -> send_lock, never the reverse.
|
|
77
|
+
# send_lock is held only for a single non-blocking mux.send call.
|
|
78
|
+
self._send_lock = threading.Lock()
|
|
79
|
+
self._map_lock = threading.Lock()
|
|
80
|
+
self._stop = threading.Event()
|
|
81
|
+
self.closed = False
|
|
82
|
+
self._pending: dict[int, _PendingOpen] = {}
|
|
83
|
+
self._chans: dict[int, socket.socket] = {} # our id -> local socket
|
|
84
|
+
self._srv_to_my: dict[int, int] = {} # server id -> our id
|
|
85
|
+
self._my_to_srv: dict[int, int] = {} # our id -> server id
|
|
86
|
+
self._consumed: dict[int, int] = {}
|
|
87
|
+
self._pending_in: dict[int, bytes] = {} # local->server backlog
|
|
88
|
+
self._pending_out: dict[int, bytes] = {} # server->local backlog
|
|
89
|
+
self._eof_local: set[int] = set() # local EOF seen, backlog pending
|
|
90
|
+
self._send_window: dict[int, int] = {} # our id -> remaining window
|
|
91
|
+
self._send_max: dict[int, int] = {}
|
|
92
|
+
self._eof_sent: set[int] = set()
|
|
93
|
+
self._th = threading.Thread(target=self._pump, daemon=True)
|
|
94
|
+
|
|
95
|
+
# transport IO
|
|
96
|
+
def _send(self, payload: bytes) -> None:
|
|
97
|
+
with self._send_lock:
|
|
98
|
+
self._mux.send(payload)
|
|
99
|
+
|
|
100
|
+
def _timeout(self) -> float:
|
|
101
|
+
try:
|
|
102
|
+
return float(self._mux.timeout)
|
|
103
|
+
except Exception:
|
|
104
|
+
return 10.0
|
|
105
|
+
|
|
106
|
+
def _wait_pending(self, my_id: int, pend: _PendingOpen, timeout: float) -> None:
|
|
107
|
+
"""
|
|
108
|
+
Wait for a channel-open confirm while draining mux queues.
|
|
109
|
+
|
|
110
|
+
Every installed channel (not just ours) is drained through
|
|
111
|
+
_handle while we wait, so a slow/dead peer on one accept cannot
|
|
112
|
+
head-of-line-block the rest; no lock is held while waiting.
|
|
113
|
+
"""
|
|
114
|
+
deadline = time.monotonic() + timeout
|
|
115
|
+
# The confirm for THIS channel must have a queue to land in. Note
|
|
116
|
+
# it is NOT installed yet, so _pump_drain_mux (installed channels
|
|
117
|
+
# + catch-all) will not see it: drain it explicitly below.
|
|
118
|
+
my_q = self._mux.register(my_id)
|
|
119
|
+
while not pend.event.is_set():
|
|
120
|
+
if self._mux.dead:
|
|
121
|
+
raise ForwardError("transport closed during channel open")
|
|
122
|
+
if time.monotonic() > deadline:
|
|
123
|
+
break
|
|
124
|
+
if not self._pump_drain_mux():
|
|
125
|
+
raise ForwardError("forwarder draining failed during channel open")
|
|
126
|
+
try:
|
|
127
|
+
p = my_q.get_nowait()
|
|
128
|
+
except queue.Empty:
|
|
129
|
+
pass
|
|
130
|
+
else:
|
|
131
|
+
if isinstance(p, (bytes, bytearray)):
|
|
132
|
+
self._handle(p)
|
|
133
|
+
if pend.event.is_set():
|
|
134
|
+
break
|
|
135
|
+
if self._stop.is_set():
|
|
136
|
+
break
|
|
137
|
+
time.sleep(0.01)
|
|
138
|
+
if not pend.event.is_set():
|
|
139
|
+
raise ForwardError("channel open confirm timeout")
|
|
140
|
+
if pend.failure:
|
|
141
|
+
raise ForwardError("server refused channel open")
|
|
142
|
+
|
|
143
|
+
# to be specialised
|
|
144
|
+
def _handle(self, p: bytes) -> None:
|
|
145
|
+
raise NotImplementedError
|
|
146
|
+
|
|
147
|
+
def _pump(self) -> None:
|
|
148
|
+
raise NotImplementedError
|
|
149
|
+
|
|
150
|
+
def _pump_drain_mux(self) -> bool:
|
|
151
|
+
"""Drain our channel queues + catch-all once. False to shut down."""
|
|
152
|
+
with self._map_lock:
|
|
153
|
+
chan_ids = list(self._chans)
|
|
154
|
+
catch = self._mux.catch_all()
|
|
155
|
+
queues = []
|
|
156
|
+
for k in chan_ids:
|
|
157
|
+
q = self._mux._queue_for(k)
|
|
158
|
+
if q is not None:
|
|
159
|
+
queues.append(q)
|
|
160
|
+
for q in queues + [catch]:
|
|
161
|
+
while True:
|
|
162
|
+
try:
|
|
163
|
+
p = q.get_nowait()
|
|
164
|
+
except queue.Empty:
|
|
165
|
+
break
|
|
166
|
+
if not isinstance(p, (bytes, bytearray)):
|
|
167
|
+
continue # mux sentinel: death is reported via mux.dead
|
|
168
|
+
try:
|
|
169
|
+
self._handle(p)
|
|
170
|
+
except ForwardError:
|
|
171
|
+
return False
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
if self._mux.dead:
|
|
175
|
+
return False
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
# shared channel helpers (brief map_lock)
|
|
179
|
+
def _install_channel(self, my_id: int, sock: socket.socket, srv: int, window: int, maxpacket: int) -> None:
|
|
180
|
+
# The mux queue must exist before any DATA can arrive (server-
|
|
181
|
+
# initiated opens never went through _wait_pending registration).
|
|
182
|
+
try:
|
|
183
|
+
self._mux.register(my_id)
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
with self._map_lock:
|
|
187
|
+
if my_id in self._chans:
|
|
188
|
+
raise ForwardError(f"channel id collision: {my_id} already open")
|
|
189
|
+
self._pending.pop(my_id, None)
|
|
190
|
+
self._chans[my_id] = sock
|
|
191
|
+
self._srv_to_my[srv] = my_id
|
|
192
|
+
self._my_to_srv[my_id] = srv
|
|
193
|
+
self._consumed[my_id] = 0
|
|
194
|
+
# Send window is keyed by OUR id: WINDOW_ADJUST from the peer
|
|
195
|
+
# carries our channel number as recipient (RFC 4254 section 5.2).
|
|
196
|
+
self._send_window[my_id] = window
|
|
197
|
+
self._send_max[my_id] = max(1024, maxpacket)
|
|
198
|
+
|
|
199
|
+
def _resolve_my_locked(self, recipient: int) -> int | None:
|
|
200
|
+
"""
|
|
201
|
+
Map an inbound recipient field to our channel id.
|
|
202
|
+
|
|
203
|
+
Peers address our channel number; accept the peer's own number too
|
|
204
|
+
(liberal in what you accept - some stacks echo it).
|
|
205
|
+
"""
|
|
206
|
+
if recipient in self._chans:
|
|
207
|
+
return recipient
|
|
208
|
+
return self._srv_to_my.get(recipient)
|
|
209
|
+
|
|
210
|
+
def _on_data(self, recipient: int, data: bytes) -> None:
|
|
211
|
+
"""Server->local direction with partial-write-safe backlog."""
|
|
212
|
+
with self._map_lock:
|
|
213
|
+
my_id = self._resolve_my_locked(recipient)
|
|
214
|
+
if my_id is None:
|
|
215
|
+
return
|
|
216
|
+
s = self._chans.get(my_id)
|
|
217
|
+
if s is None:
|
|
218
|
+
return
|
|
219
|
+
data = self._pending_out.pop(my_id, b"") + data
|
|
220
|
+
if len(data) > CH.MAX_BUFFER:
|
|
221
|
+
# Slow local reader vs fast server: fail the channel closed
|
|
222
|
+
# instead of letting one connection OOM the process.
|
|
223
|
+
over = True
|
|
224
|
+
else:
|
|
225
|
+
over = False
|
|
226
|
+
if over:
|
|
227
|
+
self._drop(my_id)
|
|
228
|
+
return
|
|
229
|
+
try:
|
|
230
|
+
n = s.send(data)
|
|
231
|
+
except BlockingIOError:
|
|
232
|
+
with self._map_lock:
|
|
233
|
+
if my_id in self._chans:
|
|
234
|
+
self._pending_out[my_id] = data
|
|
235
|
+
return
|
|
236
|
+
except Exception:
|
|
237
|
+
self._drop(my_id)
|
|
238
|
+
return
|
|
239
|
+
if n < len(data):
|
|
240
|
+
with self._map_lock:
|
|
241
|
+
if my_id in self._chans:
|
|
242
|
+
self._pending_out[my_id] = data[n:]
|
|
243
|
+
data = data[:n]
|
|
244
|
+
self._credit(my_id, len(data))
|
|
245
|
+
|
|
246
|
+
def _credit(self, my_id: int, n: int) -> None:
|
|
247
|
+
with self._map_lock:
|
|
248
|
+
self._consumed[my_id] = self._consumed.get(my_id, 0) + n
|
|
249
|
+
if self._consumed.get(my_id, 0) < CH.WINDOW_THRESHOLD:
|
|
250
|
+
return
|
|
251
|
+
srv = self._my_to_srv.get(my_id)
|
|
252
|
+
delta = self._consumed.get(my_id, 0)
|
|
253
|
+
self._consumed[my_id] = 0
|
|
254
|
+
if srv is not None:
|
|
255
|
+
try:
|
|
256
|
+
self._send(CH.enc_window_adjust(srv, delta))
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
def _on_window(self, recipient: int, delta: int) -> None:
|
|
261
|
+
with self._map_lock:
|
|
262
|
+
my_id = self._resolve_my_locked(recipient)
|
|
263
|
+
if my_id is None:
|
|
264
|
+
return
|
|
265
|
+
try:
|
|
266
|
+
self._send_window[my_id] = CH.check_window(
|
|
267
|
+
self._send_window.get(my_id, 0), delta)
|
|
268
|
+
except ValueError:
|
|
269
|
+
pass # bogus increment: ignore, fail closed elsewhere
|
|
270
|
+
|
|
271
|
+
def _flush_out(self, s: socket.socket) -> None:
|
|
272
|
+
with self._map_lock:
|
|
273
|
+
my_id = next((k for k, v in self._chans.items() if v is s), None)
|
|
274
|
+
if my_id is None:
|
|
275
|
+
return
|
|
276
|
+
data = self._pending_out.pop(my_id, b"")
|
|
277
|
+
if not data:
|
|
278
|
+
return
|
|
279
|
+
try:
|
|
280
|
+
n = s.send(data)
|
|
281
|
+
except BlockingIOError:
|
|
282
|
+
with self._map_lock:
|
|
283
|
+
if my_id in self._chans:
|
|
284
|
+
self._pending_out[my_id] = data
|
|
285
|
+
return
|
|
286
|
+
except Exception:
|
|
287
|
+
self._drop(my_id)
|
|
288
|
+
return
|
|
289
|
+
if n < len(data):
|
|
290
|
+
with self._map_lock:
|
|
291
|
+
if my_id in self._chans:
|
|
292
|
+
self._pending_out[my_id] = data[n:]
|
|
293
|
+
# Credit the server window for what actually left, like _on_data.
|
|
294
|
+
self._credit(my_id, n)
|
|
295
|
+
|
|
296
|
+
def _on_local(self, s: socket.socket) -> None:
|
|
297
|
+
with self._map_lock:
|
|
298
|
+
my_id = next((k for k, v in self._chans.items() if v is s), None)
|
|
299
|
+
if my_id is None:
|
|
300
|
+
return
|
|
301
|
+
data = self._pending_in.pop(my_id, b"")
|
|
302
|
+
try:
|
|
303
|
+
got = s.recv(32768)
|
|
304
|
+
except BlockingIOError:
|
|
305
|
+
got = None # spurious readiness: keep channel
|
|
306
|
+
except Exception:
|
|
307
|
+
self._drop(my_id)
|
|
308
|
+
return
|
|
309
|
+
if got is None:
|
|
310
|
+
if data:
|
|
311
|
+
self._forward_local(my_id, data)
|
|
312
|
+
return
|
|
313
|
+
if got == b"":
|
|
314
|
+
# Local EOF is terminal for this direction: arm it, flush what
|
|
315
|
+
# remains, and stop selecting the socket (it would spin: EOF
|
|
316
|
+
# sockets are always "readable").
|
|
317
|
+
with self._map_lock:
|
|
318
|
+
if my_id in self._chans:
|
|
319
|
+
self._eof_local.add(my_id)
|
|
320
|
+
if data:
|
|
321
|
+
self._forward_local(my_id, data)
|
|
322
|
+
self._maybe_half_close(my_id)
|
|
323
|
+
return
|
|
324
|
+
self._forward_local(my_id, data + got)
|
|
325
|
+
|
|
326
|
+
def _flush_in(self, my_id: int) -> None:
|
|
327
|
+
"""Retry a backlogged local->server write (window opened up)."""
|
|
328
|
+
with self._map_lock:
|
|
329
|
+
if my_id not in self._chans:
|
|
330
|
+
self._eof_local.discard(my_id)
|
|
331
|
+
return
|
|
332
|
+
data = self._pending_in.pop(my_id, b"")
|
|
333
|
+
if not data:
|
|
334
|
+
return
|
|
335
|
+
self._forward_local(my_id, data)
|
|
336
|
+
self._maybe_half_close(my_id)
|
|
337
|
+
|
|
338
|
+
def _maybe_half_close(self, my_id: int) -> None:
|
|
339
|
+
"""Deliver an armed local EOF once its backlog fully drained."""
|
|
340
|
+
with self._map_lock:
|
|
341
|
+
armed = my_id in self._eof_local
|
|
342
|
+
drained = not self._pending_in.get(my_id)
|
|
343
|
+
live = my_id in self._chans
|
|
344
|
+
if armed and drained and live:
|
|
345
|
+
self._eof_local.discard(my_id)
|
|
346
|
+
do_half = True
|
|
347
|
+
else:
|
|
348
|
+
do_half = False
|
|
349
|
+
if do_half:
|
|
350
|
+
self._half_close(my_id)
|
|
351
|
+
|
|
352
|
+
def _forward_local(self, my_id: int, data: bytes) -> None:
|
|
353
|
+
with self._map_lock:
|
|
354
|
+
srv = self._my_to_srv.get(my_id)
|
|
355
|
+
if srv is None:
|
|
356
|
+
return
|
|
357
|
+
# Honor the server's maxpacket but never exceed ours: a rogue
|
|
358
|
+
# server announcing megabytes would make enc_data raise.
|
|
359
|
+
maxp = min(self._send_max.get(my_id, 32768), CH.MAXPKT)
|
|
360
|
+
if len(self._pending_in.get(my_id, b"")) + len(data) > CH.MAX_BUFFER:
|
|
361
|
+
over = True
|
|
362
|
+
else:
|
|
363
|
+
over = False
|
|
364
|
+
if over:
|
|
365
|
+
self._drop(my_id)
|
|
366
|
+
return
|
|
367
|
+
off = 0
|
|
368
|
+
try:
|
|
369
|
+
while off < len(data):
|
|
370
|
+
chunk = data[off:off + maxp]
|
|
371
|
+
with self._map_lock:
|
|
372
|
+
window = self._send_window.get(my_id, 0)
|
|
373
|
+
if window < len(chunk):
|
|
374
|
+
self._pending_in[my_id] = data[off:]
|
|
375
|
+
return
|
|
376
|
+
self._send_window[my_id] = window - len(chunk)
|
|
377
|
+
self._send(CH.enc_data(srv, chunk))
|
|
378
|
+
off += len(chunk)
|
|
379
|
+
except Exception:
|
|
380
|
+
self._drop(my_id)
|
|
381
|
+
return
|
|
382
|
+
if off < len(data):
|
|
383
|
+
with self._map_lock:
|
|
384
|
+
if my_id in self._chans:
|
|
385
|
+
self._pending_in[my_id] = data[off:]
|
|
386
|
+
|
|
387
|
+
def _half_close(self, my_id: int) -> None:
|
|
388
|
+
"""Local EOF: CHANNEL_EOF to the server, keep the reverse path open."""
|
|
389
|
+
with self._map_lock:
|
|
390
|
+
if my_id not in self._chans or my_id in self._eof_sent:
|
|
391
|
+
return
|
|
392
|
+
srv = self._my_to_srv.get(my_id)
|
|
393
|
+
self._eof_sent.add(my_id)
|
|
394
|
+
try:
|
|
395
|
+
s = self._chans.get(my_id)
|
|
396
|
+
if s is not None:
|
|
397
|
+
try:
|
|
398
|
+
s.shutdown(socket.SHUT_RD)
|
|
399
|
+
except Exception:
|
|
400
|
+
pass
|
|
401
|
+
except Exception:
|
|
402
|
+
pass
|
|
403
|
+
if srv is not None:
|
|
404
|
+
try:
|
|
405
|
+
self._send(CH.enc_eof(srv))
|
|
406
|
+
except Exception:
|
|
407
|
+
pass
|
|
408
|
+
|
|
409
|
+
def _on_remote_eof(self, recipient: int) -> None:
|
|
410
|
+
with self._map_lock:
|
|
411
|
+
my_id = self._resolve_my_locked(recipient)
|
|
412
|
+
if my_id is None:
|
|
413
|
+
return
|
|
414
|
+
s = self._chans.get(my_id)
|
|
415
|
+
if s is not None:
|
|
416
|
+
try:
|
|
417
|
+
s.shutdown(socket.SHUT_WR)
|
|
418
|
+
except Exception:
|
|
419
|
+
pass
|
|
420
|
+
|
|
421
|
+
def _on_remote_close(self, recipient: int) -> None:
|
|
422
|
+
with self._map_lock:
|
|
423
|
+
my_id = self._resolve_my_locked(recipient)
|
|
424
|
+
if my_id is not None:
|
|
425
|
+
self._drop(my_id)
|
|
426
|
+
|
|
427
|
+
def _route_confirm(self, my_id: int, srv: int, window: int, maxpacket: int) -> bool:
|
|
428
|
+
"""Complete a pending open. True if it was ours."""
|
|
429
|
+
with self._map_lock:
|
|
430
|
+
pend = self._pending.get(my_id)
|
|
431
|
+
if pend is None:
|
|
432
|
+
return False
|
|
433
|
+
pend.srv = srv
|
|
434
|
+
pend.window = window
|
|
435
|
+
pend.maxpacket = max(1024, maxpacket)
|
|
436
|
+
pend.event.set()
|
|
437
|
+
return True
|
|
438
|
+
|
|
439
|
+
def _route_failure(self, my_id: int) -> bool:
|
|
440
|
+
with self._map_lock:
|
|
441
|
+
pend = self._pending.get(my_id)
|
|
442
|
+
if pend is None:
|
|
443
|
+
return False
|
|
444
|
+
pend.failure = True
|
|
445
|
+
pend.event.set()
|
|
446
|
+
return True
|
|
447
|
+
|
|
448
|
+
def _drop(self, my_id: int) -> None:
|
|
449
|
+
with self._map_lock:
|
|
450
|
+
s = self._chans.pop(my_id, None)
|
|
451
|
+
self._consumed.pop(my_id, None)
|
|
452
|
+
self._pending_in.pop(my_id, None)
|
|
453
|
+
self._pending_out.pop(my_id, None)
|
|
454
|
+
self._eof_sent.discard(my_id)
|
|
455
|
+
self._eof_local.discard(my_id)
|
|
456
|
+
self._pending.pop(my_id, None)
|
|
457
|
+
srv = self._my_to_srv.pop(my_id, None)
|
|
458
|
+
if srv is not None:
|
|
459
|
+
self._srv_to_my.pop(srv, None)
|
|
460
|
+
self._send_window.pop(my_id, None)
|
|
461
|
+
self._send_max.pop(my_id, None)
|
|
462
|
+
try:
|
|
463
|
+
self._mux.unregister(my_id)
|
|
464
|
+
except Exception:
|
|
465
|
+
pass
|
|
466
|
+
if s is not None:
|
|
467
|
+
try:
|
|
468
|
+
s.close()
|
|
469
|
+
except Exception:
|
|
470
|
+
pass
|
|
471
|
+
if srv is not None:
|
|
472
|
+
try:
|
|
473
|
+
self._send(CH.enc_close(srv))
|
|
474
|
+
except Exception:
|
|
475
|
+
pass
|
|
476
|
+
|
|
477
|
+
def _close_channels(self) -> None:
|
|
478
|
+
for my_id in list(self._chans):
|
|
479
|
+
self._drop(my_id) # closes the local socket, sends CHANNEL_CLOSE
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
class LocalForwarder(BaseForwarder):
|
|
483
|
+
def __init__(self, mux, lport: int, rhost: str, rport: int,
|
|
484
|
+
bind: str = "127.0.0.1"):
|
|
485
|
+
super().__init__(mux)
|
|
486
|
+
self._target = (rhost, rport)
|
|
487
|
+
self._bind = bind
|
|
488
|
+
self._ls = _listen(bind, lport)
|
|
489
|
+
self.port = self._ls.getsockname()[1]
|
|
490
|
+
|
|
491
|
+
def start(self) -> "LocalForwarder":
|
|
492
|
+
self._th.start()
|
|
493
|
+
return self
|
|
494
|
+
|
|
495
|
+
def close(self) -> None:
|
|
496
|
+
self.closed = True
|
|
497
|
+
self._stop.set()
|
|
498
|
+
try:
|
|
499
|
+
self._ls.close()
|
|
500
|
+
except Exception:
|
|
501
|
+
pass
|
|
502
|
+
self._close_channels()
|
|
503
|
+
# NOTE: the client-owned mux keeps running (exec may continue).
|
|
504
|
+
try:
|
|
505
|
+
if self._th.is_alive():
|
|
506
|
+
self._th.join(timeout=2.0)
|
|
507
|
+
except Exception:
|
|
508
|
+
pass
|
|
509
|
+
|
|
510
|
+
def _pump(self) -> None:
|
|
511
|
+
try:
|
|
512
|
+
while not self._stop.is_set():
|
|
513
|
+
if not self._pump_drain_mux():
|
|
514
|
+
return
|
|
515
|
+
with self._map_lock:
|
|
516
|
+
chans = [(k, v) for k, v in self._chans.items() if k not in self._eof_local]
|
|
517
|
+
wlist = [self._chans[k] for k in self._pending_out if k in self._chans]
|
|
518
|
+
eof_backlog = [k for k in self._eof_local if k in self._chans and self._pending_in.get(k)]
|
|
519
|
+
for my_id in eof_backlog:
|
|
520
|
+
self._flush_in(my_id)
|
|
521
|
+
try:
|
|
522
|
+
rd, wr, _ = select.select([self._ls] + [s for _, s in chans], wlist, [], 0.2)
|
|
523
|
+
except Exception:
|
|
524
|
+
if self.closed:
|
|
525
|
+
return
|
|
526
|
+
continue
|
|
527
|
+
for s in wr:
|
|
528
|
+
self._flush_out(s)
|
|
529
|
+
for s in rd:
|
|
530
|
+
if s is self._ls:
|
|
531
|
+
try:
|
|
532
|
+
conn, _ = self._ls.accept()
|
|
533
|
+
except Exception:
|
|
534
|
+
continue
|
|
535
|
+
try:
|
|
536
|
+
self._open_channel(conn)
|
|
537
|
+
except Exception:
|
|
538
|
+
try:
|
|
539
|
+
conn.close()
|
|
540
|
+
except Exception:
|
|
541
|
+
pass
|
|
542
|
+
else:
|
|
543
|
+
self._on_local(s)
|
|
544
|
+
finally:
|
|
545
|
+
# A dying pump must not strand open sockets/queues behind.
|
|
546
|
+
self._stop.set()
|
|
547
|
+
self._close_channels()
|
|
548
|
+
|
|
549
|
+
def _open_channel(self, conn: socket.socket) -> None:
|
|
550
|
+
# Channel ids come from the single mux-shared namespace: disjoint
|
|
551
|
+
# local counters would eventually collide and misroute packets.
|
|
552
|
+
my_id = self._mux.alloc_chan()
|
|
553
|
+
with self._map_lock:
|
|
554
|
+
pend = _PendingOpen()
|
|
555
|
+
self._pending[my_id] = pend
|
|
556
|
+
try:
|
|
557
|
+
peer = conn.getpeername()
|
|
558
|
+
except Exception:
|
|
559
|
+
peer = ("127.0.0.1", 0)
|
|
560
|
+
self._send(CH.enc_open_direct_tcpip(my_id, self._target[0], self._target[1], orig_host=peer[0], orig_port=peer[1]))
|
|
561
|
+
# Wait for OUR confirm while other channels keep flowing.
|
|
562
|
+
try:
|
|
563
|
+
self._wait_pending(my_id, pend, self._timeout())
|
|
564
|
+
except Exception:
|
|
565
|
+
with self._map_lock:
|
|
566
|
+
self._pending.pop(my_id, None)
|
|
567
|
+
try:
|
|
568
|
+
self._mux.unregister(my_id)
|
|
569
|
+
except Exception:
|
|
570
|
+
pass
|
|
571
|
+
raise
|
|
572
|
+
assert pend.srv is not None
|
|
573
|
+
self._install_channel(my_id, conn, pend.srv, pend.window, pend.maxpacket)
|
|
574
|
+
try:
|
|
575
|
+
conn.setblocking(False)
|
|
576
|
+
except Exception:
|
|
577
|
+
pass
|
|
578
|
+
|
|
579
|
+
def _handle(self, p: bytes) -> None:
|
|
580
|
+
r = W.Reader(p)
|
|
581
|
+
try:
|
|
582
|
+
m = r.byte()
|
|
583
|
+
except Exception:
|
|
584
|
+
return
|
|
585
|
+
if m in (2, 4, 20, 80, 81, 82):
|
|
586
|
+
# Service traffic never reaches here (mux handles it); a KEX
|
|
587
|
+
# broadcast means rekey with open channels: fail loudly.
|
|
588
|
+
if m == 20:
|
|
589
|
+
raise ForwardError("peer started rekey during forwarding: close it and call rekey()")
|
|
590
|
+
return
|
|
591
|
+
if m == CH.MSG_CHANNEL_OPEN_CONFIRM:
|
|
592
|
+
try:
|
|
593
|
+
rcpt, srv, w, mx = r.u32(), r.u32(), r.u32(), r.u32()
|
|
594
|
+
except Exception:
|
|
595
|
+
return
|
|
596
|
+
self._route_confirm(rcpt, srv, w, mx)
|
|
597
|
+
return
|
|
598
|
+
if m == CH.MSG_CHANNEL_OPEN:
|
|
599
|
+
# A local (-L) forwarder never expects server-initiated opens:
|
|
600
|
+
# refuse so the peer doesn't hang waiting for an answer.
|
|
601
|
+
rr = W.Reader(p)
|
|
602
|
+
try:
|
|
603
|
+
rr.byte()
|
|
604
|
+
rr.string()
|
|
605
|
+
srv = rr.u32()
|
|
606
|
+
except Exception:
|
|
607
|
+
return
|
|
608
|
+
try:
|
|
609
|
+
self._send(bytes([CH.MSG_CHANNEL_OPEN_FAILURE])
|
|
610
|
+
+ W.enc_u32(srv)
|
|
611
|
+
+ W.enc_u32(3)
|
|
612
|
+
+ W.enc_string(b"forwarding not supported here")
|
|
613
|
+
+ W.enc_string(b""))
|
|
614
|
+
except Exception:
|
|
615
|
+
pass
|
|
616
|
+
return
|
|
617
|
+
if m == CH.MSG_CHANNEL_OPEN_FAILURE:
|
|
618
|
+
try:
|
|
619
|
+
rcpt = r.u32()
|
|
620
|
+
except Exception:
|
|
621
|
+
return
|
|
622
|
+
if not self._route_failure(rcpt):
|
|
623
|
+
return
|
|
624
|
+
return
|
|
625
|
+
if m == CH.MSG_CHANNEL_DATA:
|
|
626
|
+
try:
|
|
627
|
+
rcpt, data = r.u32(), r.string()
|
|
628
|
+
except Exception:
|
|
629
|
+
return
|
|
630
|
+
self._on_data(rcpt, data)
|
|
631
|
+
return
|
|
632
|
+
if m == CH.MSG_WINDOW_ADJUST:
|
|
633
|
+
try:
|
|
634
|
+
rcpt, delta = r.u32(), r.u32()
|
|
635
|
+
except Exception:
|
|
636
|
+
return
|
|
637
|
+
self._on_window(rcpt, delta)
|
|
638
|
+
return
|
|
639
|
+
if m == CH.MSG_CHANNEL_EOF:
|
|
640
|
+
try:
|
|
641
|
+
rcpt = r.u32()
|
|
642
|
+
except Exception:
|
|
643
|
+
return
|
|
644
|
+
self._on_remote_eof(rcpt)
|
|
645
|
+
return
|
|
646
|
+
if m == CH.MSG_CHANNEL_CLOSE:
|
|
647
|
+
try:
|
|
648
|
+
rcpt = r.u32()
|
|
649
|
+
except Exception:
|
|
650
|
+
return
|
|
651
|
+
self._on_remote_close(rcpt)
|
|
652
|
+
return
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def enc_global_tcpip_forward(bind: str, port: int) -> bytes:
|
|
656
|
+
"""
|
|
657
|
+
SSH_MSG_GLOBAL_REQUEST for tcpip-forward (RFC 4254 section 7.1).
|
|
658
|
+
"""
|
|
659
|
+
return (bytes([80]) + W.enc_string(b"tcpip-forward") + W.enc_bool(True) + W.enc_string(bind.encode()) + W.enc_u32(port))
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def enc_global_cancel_forward(bind: str, port: int) -> bytes:
|
|
663
|
+
"""
|
|
664
|
+
cancel-tcpip-forward. want_reply=False: fire-and-forget on close.
|
|
665
|
+
"""
|
|
666
|
+
return (bytes([80]) + W.enc_string(b"cancel-tcpip-forward") + W.enc_bool(False) + W.enc_string(bind.encode()) + W.enc_u32(port))
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
class RemoteForwarder(BaseForwarder):
|
|
670
|
+
"""
|
|
671
|
+
Server-side listener relayed to a local target (`ssh -R`).
|
|
672
|
+
|
|
673
|
+
The server binds (bind, sport) - sport 0 means auto-assign, the real
|
|
674
|
+
port lands in .port (OpenSSH returns it in REQUEST_SUCCESS). Inbound
|
|
675
|
+
connections arrive as forwarded-tcpip channel opens; each is dialed
|
|
676
|
+
to (rhost, rport) locally and relayed in the background loop.
|
|
677
|
+
Same single-forwarder-per-connection rule as -L (see client).
|
|
678
|
+
"""
|
|
679
|
+
|
|
680
|
+
def __init__(self, mux, sport: int, rhost: str, rport: int, bind: str = "localhost"):
|
|
681
|
+
super().__init__(mux)
|
|
682
|
+
self._bind = bind
|
|
683
|
+
self._req_port = sport
|
|
684
|
+
self.port = sport
|
|
685
|
+
self._target = (rhost, rport)
|
|
686
|
+
|
|
687
|
+
def start(self) -> "RemoteForwarder":
|
|
688
|
+
"""
|
|
689
|
+
Register the remote listener. Other channel traffic arriving
|
|
690
|
+
during registration is handled, never dropped.
|
|
691
|
+
"""
|
|
692
|
+
self._send(enc_global_tcpip_forward(self._bind, self._req_port))
|
|
693
|
+
deadline = time.monotonic() + self._timeout()
|
|
694
|
+
catch = self._mux.catch_all()
|
|
695
|
+
while True:
|
|
696
|
+
if self._mux.dead:
|
|
697
|
+
raise ForwardError("transport closed during tcpip-forward")
|
|
698
|
+
remaining = deadline - time.monotonic()
|
|
699
|
+
if remaining <= 0:
|
|
700
|
+
raise ForwardError(f"tcpip-forward {self._bind}:{self._req_port} timed out")
|
|
701
|
+
try:
|
|
702
|
+
p = catch.get(timeout=min(0.5, remaining))
|
|
703
|
+
except queue.Empty:
|
|
704
|
+
if self._stop.is_set():
|
|
705
|
+
raise ForwardError("forwarder closing during tcpip-forward")
|
|
706
|
+
continue
|
|
707
|
+
if not isinstance(p, (bytes, bytearray)):
|
|
708
|
+
continue
|
|
709
|
+
r = W.Reader(p)
|
|
710
|
+
try:
|
|
711
|
+
m = r.byte()
|
|
712
|
+
except Exception:
|
|
713
|
+
continue
|
|
714
|
+
if m == 81: # REQUEST_SUCCESS (+optional bound port, OpenSSH sends it)
|
|
715
|
+
try:
|
|
716
|
+
rr = W.Reader(p)
|
|
717
|
+
rr.byte()
|
|
718
|
+
self.port = rr.u32()
|
|
719
|
+
except Exception:
|
|
720
|
+
pass
|
|
721
|
+
break
|
|
722
|
+
if m == 82:
|
|
723
|
+
raise ForwardError(f"server refused -R {self._bind}:{self._req_port}")
|
|
724
|
+
self._handle(p)
|
|
725
|
+
self._th.start()
|
|
726
|
+
return self
|
|
727
|
+
|
|
728
|
+
def close(self) -> None:
|
|
729
|
+
self.closed = True
|
|
730
|
+
self._stop.set()
|
|
731
|
+
try:
|
|
732
|
+
self._send(enc_global_cancel_forward(self._bind, self.port))
|
|
733
|
+
except Exception:
|
|
734
|
+
pass
|
|
735
|
+
self._close_channels()
|
|
736
|
+
# NOTE: the client-owned mux keeps running (exec may continue).
|
|
737
|
+
try:
|
|
738
|
+
if self._th.is_alive():
|
|
739
|
+
self._th.join(timeout=2.0)
|
|
740
|
+
except Exception:
|
|
741
|
+
pass
|
|
742
|
+
|
|
743
|
+
def _pump(self) -> None:
|
|
744
|
+
try:
|
|
745
|
+
while not self._stop.is_set():
|
|
746
|
+
if not self._pump_drain_mux():
|
|
747
|
+
return
|
|
748
|
+
with self._map_lock:
|
|
749
|
+
chans = [(k, v) for k, v in self._chans.items() if k not in self._eof_local]
|
|
750
|
+
wlist = [self._chans[k] for k in self._pending_out if k in self._chans]
|
|
751
|
+
eof_backlog = [k for k in self._eof_local if k in self._chans and self._pending_in.get(k)]
|
|
752
|
+
for my_id in eof_backlog:
|
|
753
|
+
self._flush_in(my_id)
|
|
754
|
+
if not chans and not wlist:
|
|
755
|
+
# No channels yet: idle-wait so a dead transport is noticed
|
|
756
|
+
# via the queue drain above instead of blocking in select.
|
|
757
|
+
time.sleep(0.05)
|
|
758
|
+
continue
|
|
759
|
+
try:
|
|
760
|
+
rd, wr, _ = select.select([s for _, s in chans], wlist, [], 0.2)
|
|
761
|
+
except Exception:
|
|
762
|
+
if self.closed:
|
|
763
|
+
return
|
|
764
|
+
continue
|
|
765
|
+
for s in wr:
|
|
766
|
+
self._flush_out(s)
|
|
767
|
+
for s in rd:
|
|
768
|
+
self._on_local(s)
|
|
769
|
+
finally:
|
|
770
|
+
# A dying pump must not strand open sockets/queues behind.
|
|
771
|
+
self._stop.set()
|
|
772
|
+
self._close_channels()
|
|
773
|
+
|
|
774
|
+
def _handle(self, p: bytes) -> None:
|
|
775
|
+
r = W.Reader(p)
|
|
776
|
+
try:
|
|
777
|
+
m = r.byte()
|
|
778
|
+
except Exception:
|
|
779
|
+
return
|
|
780
|
+
if m in (2, 4, 81, 82):
|
|
781
|
+
return
|
|
782
|
+
if m == 20:
|
|
783
|
+
raise ForwardError("peer started rekey during forwarding: close it and call rekey()")
|
|
784
|
+
if m == CH.MSG_CHANNEL_OPEN:
|
|
785
|
+
rr = W.Reader(p)
|
|
786
|
+
try:
|
|
787
|
+
rr.byte()
|
|
788
|
+
if rr.string() != b"forwarded-tcpip":
|
|
789
|
+
self._refuse_open(rr)
|
|
790
|
+
return
|
|
791
|
+
srv = rr.u32()
|
|
792
|
+
srv_window = rr.u32()
|
|
793
|
+
srv_maxpacket = rr.u32()
|
|
794
|
+
rr.string()
|
|
795
|
+
rr.u32() # connected host/port (our listener side; informational)
|
|
796
|
+
rr.string()
|
|
797
|
+
rr.u32() # originator host/port (informational)
|
|
798
|
+
except Exception:
|
|
799
|
+
return
|
|
800
|
+
my_id = self._mux.alloc_chan()
|
|
801
|
+
try:
|
|
802
|
+
tcp = socket.create_connection(self._target, timeout=self._timeout())
|
|
803
|
+
except Exception:
|
|
804
|
+
try:
|
|
805
|
+
self._send(bytes([CH.MSG_CHANNEL_OPEN_FAILURE])
|
|
806
|
+
+ W.enc_u32(srv)
|
|
807
|
+
+ W.enc_u32(2)
|
|
808
|
+
+ W.enc_string(b"dial failed")
|
|
809
|
+
+ W.enc_string(b""))
|
|
810
|
+
except Exception:
|
|
811
|
+
pass
|
|
812
|
+
return
|
|
813
|
+
try:
|
|
814
|
+
self._send(bytes([CH.MSG_CHANNEL_OPEN_CONFIRM])
|
|
815
|
+
+ W.enc_u32(srv)
|
|
816
|
+
+ W.enc_u32(my_id)
|
|
817
|
+
+ W.enc_u32(CH.WINDOW)
|
|
818
|
+
+ W.enc_u32(CH.MAXPKT))
|
|
819
|
+
except Exception:
|
|
820
|
+
try:
|
|
821
|
+
tcp.close()
|
|
822
|
+
except Exception:
|
|
823
|
+
pass
|
|
824
|
+
return
|
|
825
|
+
self._install_channel(my_id, tcp, srv, srv_window, srv_maxpacket)
|
|
826
|
+
try:
|
|
827
|
+
tcp.setblocking(False)
|
|
828
|
+
except Exception:
|
|
829
|
+
pass
|
|
830
|
+
return
|
|
831
|
+
if m == CH.MSG_CHANNEL_DATA:
|
|
832
|
+
try:
|
|
833
|
+
rcpt, data = r.u32(), r.string()
|
|
834
|
+
except Exception:
|
|
835
|
+
return
|
|
836
|
+
self._on_data(rcpt, data)
|
|
837
|
+
return
|
|
838
|
+
if m == CH.MSG_WINDOW_ADJUST:
|
|
839
|
+
try:
|
|
840
|
+
rcpt, delta = r.u32(), r.u32()
|
|
841
|
+
except Exception:
|
|
842
|
+
return
|
|
843
|
+
self._on_window(rcpt, delta)
|
|
844
|
+
return
|
|
845
|
+
if m == CH.MSG_CHANNEL_EOF:
|
|
846
|
+
try:
|
|
847
|
+
rcpt = r.u32()
|
|
848
|
+
except Exception:
|
|
849
|
+
return
|
|
850
|
+
self._on_remote_eof(rcpt)
|
|
851
|
+
return
|
|
852
|
+
if m == CH.MSG_CHANNEL_CLOSE:
|
|
853
|
+
try:
|
|
854
|
+
rcpt = r.u32()
|
|
855
|
+
except Exception:
|
|
856
|
+
return
|
|
857
|
+
self._on_remote_close(rcpt)
|
|
858
|
+
return
|
|
859
|
+
|
|
860
|
+
def _refuse_open(self, rr: W.Reader) -> None:
|
|
861
|
+
try:
|
|
862
|
+
srv = rr.u32()
|
|
863
|
+
except Exception:
|
|
864
|
+
return
|
|
865
|
+
try:
|
|
866
|
+
self._send(bytes([CH.MSG_CHANNEL_OPEN_FAILURE])
|
|
867
|
+
+ W.enc_u32(srv)
|
|
868
|
+
+ W.enc_u32(3)
|
|
869
|
+
+ W.enc_string(b"unsupported channel type")
|
|
870
|
+
+ W.enc_string(b""))
|
|
871
|
+
except Exception:
|
|
872
|
+
pass
|