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/chansock.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A socket-like object tunneled over one SSH direct-tcpip channel.
|
|
3
|
+
|
|
4
|
+
Used for -J (ProxyJump): the outer SSH handshake runs over a plain TCP
|
|
5
|
+
socket, the inner one over this. Implements the small surface SyncTransport
|
|
6
|
+
needs: sendall / recv / settimeout / close.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from ccssh import channel as CH
|
|
14
|
+
from ccssh import wire as W
|
|
15
|
+
from ccssh.transport import CCSSHTimeoutError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StreamSocket(Protocol):
|
|
19
|
+
"""
|
|
20
|
+
The socket surface SyncTransport needs. Real sockets and ChannelSocket
|
|
21
|
+
both satisfy it structurally.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def sendall(self, data: bytes) -> None: ...
|
|
25
|
+
def recv(self, n: int) -> bytes: ...
|
|
26
|
+
def settimeout(self, timeout: float | None) -> None: ...
|
|
27
|
+
def close(self) -> None: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ChannelSocket(StreamSocket):
|
|
31
|
+
"""
|
|
32
|
+
One direct-tcpip channel, socket-shaped. Single-threaded by design:
|
|
33
|
+
the owning connection must not run concurrent channel operations
|
|
34
|
+
(same rule as exec/sftp/shell).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, transport, srv: int, window: int, maxpacket: int, timeout: float | None = 10.0, mux=None, chan: int | None = None):
|
|
38
|
+
self._t = transport
|
|
39
|
+
self._srv = srv
|
|
40
|
+
self._window = window
|
|
41
|
+
# Honor the server's maxpacket but never exceed ours: a rogue
|
|
42
|
+
# server announcing megabytes would make enc_data raise mid-send.
|
|
43
|
+
self._maxpacket = max(1024, min(maxpacket, CH.MAXPKT))
|
|
44
|
+
self._timeout = timeout
|
|
45
|
+
self._mux = mux
|
|
46
|
+
self._chan = chan
|
|
47
|
+
self._rbuf = b""
|
|
48
|
+
self._consumed = 0
|
|
49
|
+
self._closed = False
|
|
50
|
+
self._eof = False
|
|
51
|
+
self._skips = 0
|
|
52
|
+
|
|
53
|
+
def _deadline(self) -> float | None:
|
|
54
|
+
if self._timeout is None:
|
|
55
|
+
return None # blocking mode, like a plain socket
|
|
56
|
+
return time.monotonic() + self._timeout
|
|
57
|
+
|
|
58
|
+
def _expired(self, deadline: float | None) -> bool:
|
|
59
|
+
return deadline is not None and time.monotonic() > deadline
|
|
60
|
+
|
|
61
|
+
def _check_chan(self, rr: W.Reader) -> bool:
|
|
62
|
+
"""
|
|
63
|
+
True if this packet is addressed to our channel.
|
|
64
|
+
|
|
65
|
+
With a mux the queue is already per-channel; without one the jump
|
|
66
|
+
connection carries only this channel. Either way, a foreign id is
|
|
67
|
+
never ours - skip it instead of crediting/buffering it.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
rcpt = rr.u32()
|
|
71
|
+
except Exception:
|
|
72
|
+
return False
|
|
73
|
+
if self._chan is not None and rcpt != self._chan and rcpt != self._srv:
|
|
74
|
+
return False
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
def _recv_one(self, timeout: float | None = None):
|
|
78
|
+
"""One inbound packet: mux queue when shared, else direct read."""
|
|
79
|
+
if self._mux is not None and self._chan is not None:
|
|
80
|
+
return self._mux.recv_chan(self._chan, timeout)
|
|
81
|
+
return self._t.recv_packet(keepalive=False)
|
|
82
|
+
|
|
83
|
+
def settimeout(self, timeout: float | None) -> None:
|
|
84
|
+
# None means blocking mode, like a plain socket (never 3600s).
|
|
85
|
+
self._timeout = timeout
|
|
86
|
+
|
|
87
|
+
def sendall(self, data: bytes) -> None:
|
|
88
|
+
if self._closed:
|
|
89
|
+
raise OSError("channel closed")
|
|
90
|
+
off = 0
|
|
91
|
+
while off < len(data):
|
|
92
|
+
chunk = data[off:off + self._maxpacket]
|
|
93
|
+
self._top_up(len(chunk))
|
|
94
|
+
self._t.send_packet(CH.enc_data(self._srv, chunk))
|
|
95
|
+
off += len(chunk)
|
|
96
|
+
|
|
97
|
+
def _got_data(self, chunk: bytes) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Buffer inbound channel data and re-credit the server window.
|
|
100
|
+
"""
|
|
101
|
+
if len(self._rbuf) + len(chunk) > CH.MAX_BUFFER:
|
|
102
|
+
self._closed = True
|
|
103
|
+
raise OSError("channel receive buffer exceeded")
|
|
104
|
+
self._rbuf += chunk
|
|
105
|
+
self._consumed += len(chunk)
|
|
106
|
+
if self._consumed >= CH.WINDOW_THRESHOLD:
|
|
107
|
+
try:
|
|
108
|
+
self._t.send_packet(CH.enc_window_adjust(self._srv, self._consumed))
|
|
109
|
+
except Exception:
|
|
110
|
+
return # keep the credit: retry on the next chunk
|
|
111
|
+
self._consumed = 0
|
|
112
|
+
|
|
113
|
+
def _note_service(self) -> None:
|
|
114
|
+
"""Count non-channel service packets inside a window/recv wait."""
|
|
115
|
+
self._skips += 1
|
|
116
|
+
if self._skips > 64:
|
|
117
|
+
raise OSError("too many service messages")
|
|
118
|
+
|
|
119
|
+
def _top_up(self, need: int) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Wait for channel window, answering service traffic meanwhile.
|
|
122
|
+
The jump connection carries only this channel, so any DATA that
|
|
123
|
+
is not ours cannot arrive; anything else is service noise.
|
|
124
|
+
"""
|
|
125
|
+
deadline = self._deadline()
|
|
126
|
+
while self._window < need:
|
|
127
|
+
if self._expired(deadline):
|
|
128
|
+
raise OSError("channel send window exhausted")
|
|
129
|
+
try:
|
|
130
|
+
p = self._recv_one(timeout=None if deadline is None else max(0.01, deadline - time.monotonic()))
|
|
131
|
+
except CCSSHTimeoutError as e:
|
|
132
|
+
raise OSError("channel send window exhausted") from e
|
|
133
|
+
m = p[0]
|
|
134
|
+
rr = W.Reader(p)
|
|
135
|
+
rr.byte()
|
|
136
|
+
if m == CH.MSG_WINDOW_ADJUST:
|
|
137
|
+
if not self._check_chan(rr):
|
|
138
|
+
self._note_service()
|
|
139
|
+
continue
|
|
140
|
+
try:
|
|
141
|
+
self._window = CH.check_window(self._window, rr.u32())
|
|
142
|
+
except ValueError as e:
|
|
143
|
+
raise OSError(f"bad window adjust: {e}") from e
|
|
144
|
+
self._skips = 0
|
|
145
|
+
elif m == CH.MSG_CHANNEL_DATA:
|
|
146
|
+
if not self._check_chan(rr):
|
|
147
|
+
self._note_service()
|
|
148
|
+
continue
|
|
149
|
+
self._got_data(rr.string())
|
|
150
|
+
self._skips = 0
|
|
151
|
+
elif m == CH.MSG_CHANNEL_CLOSE:
|
|
152
|
+
self._closed = True
|
|
153
|
+
raise OSError("channel closed by peer")
|
|
154
|
+
elif m == CH.MSG_CHANNEL_EOF:
|
|
155
|
+
self._eof = True
|
|
156
|
+
continue
|
|
157
|
+
elif m == 20:
|
|
158
|
+
raise OSError("peer started rekey with an open channel")
|
|
159
|
+
else:
|
|
160
|
+
self._note_service()
|
|
161
|
+
|
|
162
|
+
def recv(self, n: int) -> bytes:
|
|
163
|
+
if n <= 0:
|
|
164
|
+
return b""
|
|
165
|
+
if (self._closed or self._eof) and not self._rbuf:
|
|
166
|
+
return b""
|
|
167
|
+
deadline = self._deadline()
|
|
168
|
+
while len(self._rbuf) < n:
|
|
169
|
+
if self._expired(deadline):
|
|
170
|
+
if self._rbuf:
|
|
171
|
+
break
|
|
172
|
+
raise CCSSHTimeoutError("channel read timeout")
|
|
173
|
+
try:
|
|
174
|
+
p = self._recv_one(timeout=None if deadline is None else max(0.01, deadline - time.monotonic()))
|
|
175
|
+
except CCSSHTimeoutError:
|
|
176
|
+
if self._rbuf:
|
|
177
|
+
break
|
|
178
|
+
raise
|
|
179
|
+
m = p[0]
|
|
180
|
+
rr = W.Reader(p)
|
|
181
|
+
rr.byte()
|
|
182
|
+
if m == CH.MSG_CHANNEL_DATA:
|
|
183
|
+
if not self._check_chan(rr):
|
|
184
|
+
self._note_service()
|
|
185
|
+
continue
|
|
186
|
+
self._got_data(rr.string())
|
|
187
|
+
self._skips = 0
|
|
188
|
+
elif m == CH.MSG_WINDOW_ADJUST:
|
|
189
|
+
if not self._check_chan(rr):
|
|
190
|
+
self._note_service()
|
|
191
|
+
continue
|
|
192
|
+
try:
|
|
193
|
+
self._window = CH.check_window(self._window, rr.u32())
|
|
194
|
+
except ValueError as e:
|
|
195
|
+
raise OSError(f"bad window adjust: {e}") from e
|
|
196
|
+
self._skips = 0
|
|
197
|
+
elif m == CH.MSG_CHANNEL_EOF:
|
|
198
|
+
rr.u32()
|
|
199
|
+
self._eof = True
|
|
200
|
+
break
|
|
201
|
+
elif m == CH.MSG_CHANNEL_CLOSE:
|
|
202
|
+
rr.u32()
|
|
203
|
+
self._closed = True
|
|
204
|
+
break
|
|
205
|
+
elif m == 20:
|
|
206
|
+
raise OSError("peer started rekey with an open channel")
|
|
207
|
+
else:
|
|
208
|
+
self._note_service()
|
|
209
|
+
out, self._rbuf = self._rbuf[:n], self._rbuf[n:]
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
def close(self) -> None:
|
|
213
|
+
if not self._closed:
|
|
214
|
+
self._closed = True
|
|
215
|
+
try:
|
|
216
|
+
self._t.send_packet(CH.enc_eof(self._srv))
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
try:
|
|
220
|
+
self._t.send_packet(CH.enc_close(self._srv))
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
if self._mux is not None and self._chan is not None:
|
|
224
|
+
try:
|
|
225
|
+
self._mux.unregister(self._chan)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|