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/cli.py
ADDED
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command line: `python -m ccssh` (or the `ccssh` entry point), OpenSSH-style.
|
|
3
|
+
|
|
4
|
+
Options may come before or after the destination (`ccssh -p 2022 h` and
|
|
5
|
+
`ccssh h -p 2022` are the same); the first bare word is the destination
|
|
6
|
+
and everything after it is the remote command. No command means an
|
|
7
|
+
interactive shell (like `ssh host`); -N means verify/forward mode.
|
|
8
|
+
Passwords and key passphrases are prompted on the terminal (never taken
|
|
9
|
+
from argv, so they stay out of `ps`); --password-env covers
|
|
10
|
+
non-interactive use. -L/-R share the connection with shell/exec through
|
|
11
|
+
the channel multiplexer (one forwarder per connection); -J takes comma
|
|
12
|
+
chains (ProxyJump). -v prints OpenSSH-style debug1 progress
|
|
13
|
+
(negotiation, host key fingerprint, auth); -vv adds full offers and
|
|
14
|
+
per-step debug2 traces. A TCP preflight dial runs before any password
|
|
15
|
+
prompt, so dead servers fail fast instead of asking for a password.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
import argparse
|
|
19
|
+
import getpass
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import socket
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
|
|
27
|
+
from ccssh.client import AuthError, CCSSHClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UsageError(Exception):
|
|
31
|
+
"""
|
|
32
|
+
Bad command line: reported as `ccssh: ...` with exit 255 (ssh-style).
|
|
33
|
+
|
|
34
|
+
argparse/SystemExit paths all funnel here so usage, unknown options
|
|
35
|
+
and bad -o values share one exit code instead of 1/2/255 soup.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Parser(argparse.ArgumentParser):
|
|
40
|
+
def error(self, message):
|
|
41
|
+
raise UsageError(message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
45
|
+
p = _Parser(prog="ccssh", description="ccssh - native from-scratch SSH client")
|
|
46
|
+
p.add_argument("destination", nargs="?", default=None, help="[user@]hostname")
|
|
47
|
+
p.add_argument("command", nargs=argparse.REMAINDER,
|
|
48
|
+
help="remote command (empty means interactive shell)")
|
|
49
|
+
p.add_argument("-p", dest="port", type=int, default=22)
|
|
50
|
+
p.add_argument("-l", dest="login", default=None)
|
|
51
|
+
p.add_argument("-c", dest="cipher_spec", default=None, metavar="cipher_spec",
|
|
52
|
+
help="ciphers like ssh -c (comma list, +alg/-alg/^alg allowed)")
|
|
53
|
+
p.add_argument("-m", dest="mac_spec", default=None, metavar="mac_spec",
|
|
54
|
+
help="MACs like ssh -m (comma list, +alg/-alg/^alg allowed)")
|
|
55
|
+
p.add_argument("-Q", dest="query", default=None, metavar="query",
|
|
56
|
+
help="query supported algorithms like ssh -Q (cipher, mac, kex, key)")
|
|
57
|
+
p.add_argument("-i", dest="identity", action="append", default=[])
|
|
58
|
+
p.add_argument("--cert", default=None, help="OpenSSH certificate file (~-cert.pub)")
|
|
59
|
+
p.add_argument("-o", dest="options", action="append", default=[],
|
|
60
|
+
help="-o KexAlgorithms=.. -o Ciphers=.. -o MACs=.. -o HostKeyAlgorithms=.. -o ConnectTimeout=.. -o StrictHostKeyChecking=.. -o UserKnownHostsFile=.. (unknown options fail)")
|
|
61
|
+
p.add_argument("-J", dest="jump", default=None,
|
|
62
|
+
help="jump host [user@]host[:port], comma chains allowed (ProxyJump)")
|
|
63
|
+
p.add_argument("-L", dest="local_fwd", action="append", default=[],
|
|
64
|
+
help="local forward [bind:]port:host:port (shares the connection)")
|
|
65
|
+
p.add_argument("-R", dest="remote_fwd", action="append", default=[],
|
|
66
|
+
help="remote forward [bind:]port:host:port (shares the connection)")
|
|
67
|
+
p.add_argument("-N", action="store_true", help="no remote command (verify/forward mode)")
|
|
68
|
+
p.add_argument("-v", action="count", default=0)
|
|
69
|
+
p.add_argument("-q", action="count", default=0, help="quiet: hush info/diagnostic output")
|
|
70
|
+
p.add_argument("-4", dest="family", action="store_const", const=socket.AF_INET, default=0, help="IPv4 only")
|
|
71
|
+
p.add_argument("-6", dest="family", action="store_const", const=socket.AF_INET6, help="IPv6 only")
|
|
72
|
+
p.add_argument("-t", dest="force_tty", action="store_true", help="force pty allocation (with a command: run it on a pty)")
|
|
73
|
+
p.add_argument("-T", dest="no_pty", action="store_true", help="disable pty allocation for the interactive shell")
|
|
74
|
+
p.add_argument("-n", dest="stdin_null", action="store_true", help="redirect stdin from /dev/null (shell sessions; exec has no stdin)")
|
|
75
|
+
p.add_argument("-V", dest="version", action="store_true", help="print version")
|
|
76
|
+
p.add_argument("--timeout", type=float, default=10.0)
|
|
77
|
+
p.add_argument("--host-policy", default="strict", choices=["strict", "accept-new", "ask", "off"])
|
|
78
|
+
p.add_argument("--known-hosts", default=None)
|
|
79
|
+
p.add_argument("--password-env", default=None, help="environment variable holding the password")
|
|
80
|
+
return p
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _local_user() -> str:
|
|
84
|
+
"""Best-effort local login name ($USER is often unset, e.g. on Termux)."""
|
|
85
|
+
for var in ("USER", "LOGNAME", "USERNAME"):
|
|
86
|
+
u = os.environ.get(var)
|
|
87
|
+
if u:
|
|
88
|
+
return u
|
|
89
|
+
try:
|
|
90
|
+
import getpass
|
|
91
|
+
return getpass.getuser()
|
|
92
|
+
except Exception:
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_destination(dest: str, login_opt: str | None) -> tuple[str, str]:
|
|
97
|
+
if "@" in dest:
|
|
98
|
+
u, h = dest.rsplit("@", 1)
|
|
99
|
+
elif login_opt:
|
|
100
|
+
h, u = dest, login_opt
|
|
101
|
+
else:
|
|
102
|
+
h, u = dest, _local_user()
|
|
103
|
+
if len(h) > 2 and h.startswith("[") and h.endswith("]"):
|
|
104
|
+
h = h[1:-1] # user@[::1] like ssh
|
|
105
|
+
return h, u
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _valid_port(p: int) -> bool:
|
|
109
|
+
return isinstance(p, int) and 1 <= p <= 65535
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_o_options(opts: list[str]) -> dict[str, str]:
|
|
113
|
+
out: dict[str, str] = {}
|
|
114
|
+
for o in opts:
|
|
115
|
+
if "=" not in o:
|
|
116
|
+
raise UsageError(f"bad -o {o!r}, expected -o Key=Value")
|
|
117
|
+
k, v = o.split("=", 1)
|
|
118
|
+
# OpenSSH option names are case-insensitive; normalise to lower.
|
|
119
|
+
out[k.strip().lower()] = v.strip()
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_KNOWN_O_OPTIONS = frozenset({
|
|
124
|
+
"kexalgorithms", "ciphers", "macs", "hostkeyalgorithms",
|
|
125
|
+
"connecttimeout", "stricthostkeychecking", "userknownhostsfile",
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _check_known_options(o_opts: dict[str, str]) -> None:
|
|
130
|
+
"""Unknown -o keys fail loudly (a typo like StricHostKeyChecking must
|
|
131
|
+
never silently keep the strict default, nor weaken it)."""
|
|
132
|
+
for k in o_opts:
|
|
133
|
+
if k not in _KNOWN_O_OPTIONS:
|
|
134
|
+
print(f"ccssh: Bad configuration option: {k}", file=sys.stderr)
|
|
135
|
+
raise UsageError(f"unknown -o option {k!r}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# Options taking a separate value (also accepted as -xVALUE / --opt=VALUE).
|
|
139
|
+
_VALUE_OPTS = frozenset({
|
|
140
|
+
"-p", "-l", "-c", "-m", "-Q", "-i", "--cert", "-o", "-J", "-L", "-R",
|
|
141
|
+
"--timeout", "--host-policy", "--known-hosts", "--password-env",
|
|
142
|
+
})
|
|
143
|
+
# Value-less short flags (may cluster: -vv, -vN, -qt).
|
|
144
|
+
_FLAG_OPTS = frozenset({"-v", "-N", "-4", "-6", "-q", "-t", "-T", "-n", "-V"})
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _expand_cluster(token: str) -> list[str]:
|
|
148
|
+
"""
|
|
149
|
+
Split a short-option cluster: -vv -> [-v, -v], -vp2222 -> [-v, -p2222].
|
|
150
|
+
Anything else (attached values like -p2022, unknowns) passes through
|
|
151
|
+
untouched for _option_kind/argparse to handle.
|
|
152
|
+
"""
|
|
153
|
+
if len(token) <= 2 or not token.startswith("-") or token.startswith("--"):
|
|
154
|
+
return [token]
|
|
155
|
+
if all("-" + ch in _FLAG_OPTS for ch in token[1:]):
|
|
156
|
+
return ["-" + ch for ch in token[1:]]
|
|
157
|
+
j = 1
|
|
158
|
+
while j < len(token) and "-" + token[j] in _FLAG_OPTS:
|
|
159
|
+
j += 1
|
|
160
|
+
if j > 1 and j < len(token) and "-" + token[j] in _VALUE_OPTS:
|
|
161
|
+
return ["-" + token[k] for k in range(1, j)] + ["-" + token[j:]]
|
|
162
|
+
return [token]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _option_kind(token: str) -> str:
|
|
166
|
+
"""
|
|
167
|
+
Classify an option-looking token: 'separate' (value is the next argv
|
|
168
|
+
item), 'none' (standalone flag / attached value), or 'unknown' (left for
|
|
169
|
+
argparse to report, like ssh reports unknown options).
|
|
170
|
+
"""
|
|
171
|
+
if token.startswith("--"):
|
|
172
|
+
name = "--" + token[2:].split("=", 1)[0]
|
|
173
|
+
if name not in _VALUE_OPTS and name not in ("--version", "--help"):
|
|
174
|
+
return "unknown"
|
|
175
|
+
if "=" in token or name not in _VALUE_OPTS:
|
|
176
|
+
return "none"
|
|
177
|
+
return "separate"
|
|
178
|
+
if len(token) == 2:
|
|
179
|
+
if token in _VALUE_OPTS:
|
|
180
|
+
return "separate"
|
|
181
|
+
return "none" if token in _FLAG_OPTS else "unknown"
|
|
182
|
+
if all("-" + ch in _FLAG_OPTS for ch in token[1:]):
|
|
183
|
+
return "none" # -vv, -vN, ...
|
|
184
|
+
if "-" + token[1] in _VALUE_OPTS:
|
|
185
|
+
return "none" # attached value: -p2022, -i/tmp/key
|
|
186
|
+
return "unknown"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def split_argv(argv: list[str] | None) -> tuple[list[str], str | None, list[str]]:
|
|
190
|
+
"""
|
|
191
|
+
SSH-style split: (option tokens, destination|None, command words).
|
|
192
|
+
|
|
193
|
+
Like OpenSSH (verified against `ssh -G`): options are recognized before
|
|
194
|
+
AND after the destination; after the destination, option parsing stops
|
|
195
|
+
at the first non-option word (or `--`), and everything from there -
|
|
196
|
+
even `-p 2022` - is the remote command, verbatim. So both
|
|
197
|
+
`ccssh -p 2022 user@host` and `ccssh user@host -p 2022` connect to
|
|
198
|
+
port 2022 with no command, while `ccssh user@host echo -p x` runs
|
|
199
|
+
`echo -p x` remotely.
|
|
200
|
+
"""
|
|
201
|
+
argv = list(argv or [])
|
|
202
|
+
opts: list[str] = []
|
|
203
|
+
dest: str | None = None
|
|
204
|
+
i, n = 0, len(argv)
|
|
205
|
+
# Phase 1: options until the destination (first bare word).
|
|
206
|
+
while i < n:
|
|
207
|
+
a = argv[i]
|
|
208
|
+
if a == "--":
|
|
209
|
+
i += 1
|
|
210
|
+
if i < n:
|
|
211
|
+
dest = argv[i]
|
|
212
|
+
i += 1
|
|
213
|
+
break
|
|
214
|
+
if len(a) > 1 and a.startswith("-"):
|
|
215
|
+
i = _take_options(argv, i, opts)
|
|
216
|
+
continue
|
|
217
|
+
dest = a
|
|
218
|
+
i += 1
|
|
219
|
+
break
|
|
220
|
+
# Phase 2: leading options still parse; first bare word starts the command.
|
|
221
|
+
while i < n:
|
|
222
|
+
a = argv[i]
|
|
223
|
+
if a == "--":
|
|
224
|
+
i += 1
|
|
225
|
+
break
|
|
226
|
+
if len(a) > 1 and a.startswith("-"):
|
|
227
|
+
i = _take_options(argv, i, opts)
|
|
228
|
+
continue
|
|
229
|
+
break
|
|
230
|
+
return opts, dest, argv[i:]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _take_options(argv: list[str], i: int, opts: list[str]) -> int:
|
|
234
|
+
"""
|
|
235
|
+
Consume one option (cluster-expanded) at argv[i] into opts, plus its
|
|
236
|
+
separate value if it takes one. Returns the next index. Unknown options
|
|
237
|
+
pass through for argparse to report; a missing value is also left for
|
|
238
|
+
argparse ("expected one argument").
|
|
239
|
+
"""
|
|
240
|
+
for tok in _expand_cluster(argv[i]):
|
|
241
|
+
opts.append(tok)
|
|
242
|
+
if _option_kind(tok) == "separate":
|
|
243
|
+
i += 1
|
|
244
|
+
if i >= len(argv):
|
|
245
|
+
return i
|
|
246
|
+
opts.append(argv[i])
|
|
247
|
+
return i + 1
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _parse_L(spec: str, default_bind: str = "127.0.0.1") -> tuple[int, str, int, str]:
|
|
251
|
+
"""
|
|
252
|
+
[bind:]lport:rhost:rport -> (lport, rhost, rport, bind).
|
|
253
|
+
rhost may be bare IPv6 (split from the right) or bracketed, and so
|
|
254
|
+
may bind: [::1]:8080:[2001:db8::1]:80. Anything ambiguous without
|
|
255
|
+
brackets is rejected with a clean usage error (exit 2, no traceback).
|
|
256
|
+
`default_bind` applies when the spec carries none: OpenSSH sends
|
|
257
|
+
"localhost" for -R (servers like serveo.net match it literally).
|
|
258
|
+
"""
|
|
259
|
+
rest, bind = spec, default_bind
|
|
260
|
+
if rest.startswith("["):
|
|
261
|
+
end = rest.find("]")
|
|
262
|
+
if end < 0 or not rest[end + 1:].startswith(":"):
|
|
263
|
+
raise UsageError(f"bad -L {spec!r}, expected [bind:]port:host:port")
|
|
264
|
+
bind, rest = rest[1:end], rest[end + 2:]
|
|
265
|
+
parts = rest.split(":")
|
|
266
|
+
try:
|
|
267
|
+
if len(parts) == 3 and "[" not in rest:
|
|
268
|
+
lp, rh, rp = parts
|
|
269
|
+
elif len(parts) == 4 and "[" not in rest:
|
|
270
|
+
bind, lp, rh, rp = parts
|
|
271
|
+
elif len(parts) >= 3:
|
|
272
|
+
lp, rp = parts[0], parts[-1]
|
|
273
|
+
rh = ":".join(parts[1:-1])
|
|
274
|
+
if rh.startswith("[") and rh.endswith("]"):
|
|
275
|
+
rh = rh[1:-1]
|
|
276
|
+
else:
|
|
277
|
+
raise UsageError(f"bad -L {spec!r}, expected [bind:]port:host:port")
|
|
278
|
+
lport, rport = int(lp), int(rp)
|
|
279
|
+
if not _valid_port(lport) or not _valid_port(rport) or not rh or not bind:
|
|
280
|
+
raise ValueError("bad port/host")
|
|
281
|
+
return lport, rh, rport, bind
|
|
282
|
+
except ValueError:
|
|
283
|
+
raise UsageError(f"bad -L {spec!r}, expected [bind:]port:host:port") from None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def parse_jump(spec: str, default_user: str) -> tuple[str, str, int]:
|
|
287
|
+
"""
|
|
288
|
+
[user@]host[:port] for -J (single hop; see parse_jump_chain).
|
|
289
|
+
"""
|
|
290
|
+
host, user = parse_destination(spec, default_user)
|
|
291
|
+
port = 22
|
|
292
|
+
m = re.match(r"^\[(.*)\](?::(\d+))?$", host)
|
|
293
|
+
if m:
|
|
294
|
+
host, port = m.group(1), int(m.group(2) or 22)
|
|
295
|
+
elif host.count(":") == 1:
|
|
296
|
+
maybe_h, maybe_p = host.rsplit(":", 1)
|
|
297
|
+
if maybe_p.isdigit():
|
|
298
|
+
host, port = maybe_h, int(maybe_p)
|
|
299
|
+
if not _valid_port(port) or not host or not user:
|
|
300
|
+
raise UsageError(f"bad -J {spec!r}, expected [user@]host[:port]")
|
|
301
|
+
return host, user, port
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def parse_jump_chain(spec: str, default_user: str) -> list[tuple[str, str, int]]:
|
|
305
|
+
"""Comma-separated -J hops, left to right (OpenSSH ProxyJump chains)."""
|
|
306
|
+
parts = [p.strip() for p in spec.split(",")]
|
|
307
|
+
if not parts or not all(parts):
|
|
308
|
+
raise UsageError(f"bad -J {spec!r}, expected [user@]host[:port][,...]")
|
|
309
|
+
return [parse_jump(p, default_user) for p in parts]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _needs_passphrase(exc: BaseException) -> bool:
|
|
313
|
+
msg = str(exc).lower()
|
|
314
|
+
return "encrypted" in msg or "password was not given" in msg or "passphrase" in msg
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class _KbdintAbort(Exception):
|
|
318
|
+
"""User aborted a keyboard-interactive prompt: stop logging in at once."""
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def default_identities() -> list[str]:
|
|
322
|
+
"""Default key files like OpenSSH (only ones that exist on disk)."""
|
|
323
|
+
cands = ["id_rsa", "id_ecdsa", "id_ecdsa_sk", "id_ed25519",
|
|
324
|
+
"id_ed25519_sk", "id_mldsa44_ed25519"]
|
|
325
|
+
out = []
|
|
326
|
+
for name in cands:
|
|
327
|
+
p = os.path.expanduser(f"~/.ssh/{name}")
|
|
328
|
+
try:
|
|
329
|
+
if os.path.isfile(p):
|
|
330
|
+
out.append(p)
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
return out
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _prompt(text: str) -> str | None:
|
|
337
|
+
try:
|
|
338
|
+
return getpass.getpass(text)
|
|
339
|
+
except (EOFError, KeyboardInterrupt):
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _make_ask_callback():
|
|
344
|
+
"""yes/no prompter for StrictHostKeyChecking=ask (ssh-like)."""
|
|
345
|
+
def _ask(prompt_text: str) -> str:
|
|
346
|
+
try:
|
|
347
|
+
ans = input(prompt_text + " ")
|
|
348
|
+
except (EOFError, KeyboardInterrupt):
|
|
349
|
+
raise _KbdintAbort()
|
|
350
|
+
return ans
|
|
351
|
+
return _ask
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _run_query(kind: str) -> None:
|
|
355
|
+
"""Print supported algorithms like `ssh -Q` (plus help)."""
|
|
356
|
+
from ccssh import algs
|
|
357
|
+
key = kind.strip().lower().replace("_", "-")
|
|
358
|
+
table = {
|
|
359
|
+
"cipher": algs.SUPPORTED_CIPHERS,
|
|
360
|
+
"cipher-auth": [c for c in algs.SUPPORTED_CIPHERS
|
|
361
|
+
if "gcm" in c or "poly1305" in c],
|
|
362
|
+
"compression": ["none"],
|
|
363
|
+
"mac": algs.SUPPORTED_MACS,
|
|
364
|
+
"kex": algs.SUPPORTED_KEX,
|
|
365
|
+
"key": algs.SUPPORTED_HOSTKEYS,
|
|
366
|
+
"key-cert": [k for k in algs.OPENSSH_KEYS if "cert" in k],
|
|
367
|
+
"key-plain": [k for k in algs.SUPPORTED_HOSTKEYS],
|
|
368
|
+
"key-sig": sorted({k.replace("-cert-v01@openssh.com", "").replace("-cert", "")
|
|
369
|
+
for k in algs.OPENSSH_KEYS}),
|
|
370
|
+
"protocol-version": ["2"],
|
|
371
|
+
"sig": sorted({k.replace("-cert-v01@openssh.com", "").replace("-cert", "")
|
|
372
|
+
for k in algs.OPENSSH_KEYS}),
|
|
373
|
+
}
|
|
374
|
+
if key == "help":
|
|
375
|
+
for name in table:
|
|
376
|
+
print(name)
|
|
377
|
+
return
|
|
378
|
+
names = table.get(key)
|
|
379
|
+
if names is None:
|
|
380
|
+
print(f"ccssh: Unsupported query '{kind}' (try -Q help)", file=sys.stderr)
|
|
381
|
+
raise UsageError(f"bad -Q {kind!r}")
|
|
382
|
+
for name in names:
|
|
383
|
+
print(name)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _preflight_tcp(host: str, port: int, family: int, timeout: float) -> None:
|
|
387
|
+
"""Fail fast if the server TCP port is unreachable — before any prompt.
|
|
388
|
+
|
|
389
|
+
Without this, the password loop prompts first and only then dials,
|
|
390
|
+
so a dead server asks for a password and *then* reports
|
|
391
|
+
'Connection refused'. A quick dial here surfaces the network error
|
|
392
|
+
immediately (OpenSSH behaviour).
|
|
393
|
+
"""
|
|
394
|
+
try:
|
|
395
|
+
infos = socket.getaddrinfo(host, port, family=family or socket.AF_UNSPEC, type=socket.SOCK_STREAM)
|
|
396
|
+
except OSError as e:
|
|
397
|
+
from ccssh.transport import CCSSHError
|
|
398
|
+
raise CCSSHError(f"connect {host}:{port}: {e}") from e
|
|
399
|
+
if not infos:
|
|
400
|
+
from ccssh.transport import CCSSHError
|
|
401
|
+
raise CCSSHError(f"connect {host}:{port}: no addresses")
|
|
402
|
+
last: OSError | None = None
|
|
403
|
+
for af, st, proto, _canon, sa in infos:
|
|
404
|
+
s: socket.socket | None = None
|
|
405
|
+
try:
|
|
406
|
+
s = socket.socket(af, st, proto)
|
|
407
|
+
s.settimeout(timeout)
|
|
408
|
+
s.connect(sa)
|
|
409
|
+
return # reachable
|
|
410
|
+
except OSError as e:
|
|
411
|
+
last = e
|
|
412
|
+
continue
|
|
413
|
+
finally:
|
|
414
|
+
if s is not None:
|
|
415
|
+
try:
|
|
416
|
+
s.close()
|
|
417
|
+
except Exception:
|
|
418
|
+
pass
|
|
419
|
+
from ccssh.transport import CCSSHError
|
|
420
|
+
raise CCSSHError(f"connect {host}:{port}: {last}")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _connect_first_working(client_opts: dict, host: str, *, port: int, username: str, password: str | None, identities: list[str], cert: str | None, verbose: int, prompt: bool = False, family: int = 0, quiet: bool = False, sock_factory: Callable[[], object] | None = None) -> CCSSHClient:
|
|
424
|
+
"""
|
|
425
|
+
Try each -i identity in turn, then password (like OpenSSH).
|
|
426
|
+
|
|
427
|
+
With prompt=True (a terminal), ask for key passphrases and retry the
|
|
428
|
+
password up to 3 times, printing `user@host's password:` like ssh does.
|
|
429
|
+
quiet hushes the diagnostic lines (ssh -q); prompts still show.
|
|
430
|
+
sock_factory dials a fresh channel socket per attempt (-J tunnels).
|
|
431
|
+
"""
|
|
432
|
+
say = verbose and not quiet
|
|
433
|
+
last: Exception | None = None
|
|
434
|
+
key_attempts: list[tuple[str | None, str | None]] = ([(key, None) for key in identities] if cert is None else [(key, cert) for key in identities] or [(None, cert)])
|
|
435
|
+
passphrase: str | None = None
|
|
436
|
+
for key, cert_file in key_attempts:
|
|
437
|
+
for _ in range(2 if (prompt and key) else 1):
|
|
438
|
+
c = CCSSHClient(**client_opts)
|
|
439
|
+
s = sock_factory() if sock_factory else None
|
|
440
|
+
if say:
|
|
441
|
+
print(f"debug1: trying identity {key}" + (f" + cert {cert_file}" if cert_file else ""), file=sys.stderr)
|
|
442
|
+
try:
|
|
443
|
+
c.connect(host, port=port, username=username, password=None, key_filename=key, cert_filename=cert_file, passphrase=passphrase, family=family, sock=s)
|
|
444
|
+
if say:
|
|
445
|
+
print(f"debug1: identity {key} accepted", file=sys.stderr)
|
|
446
|
+
return c
|
|
447
|
+
except AuthError as e:
|
|
448
|
+
last = e
|
|
449
|
+
try:
|
|
450
|
+
c.close()
|
|
451
|
+
except Exception:
|
|
452
|
+
pass
|
|
453
|
+
if s is not None:
|
|
454
|
+
try:
|
|
455
|
+
s.close()
|
|
456
|
+
except Exception:
|
|
457
|
+
pass
|
|
458
|
+
if prompt and key and passphrase is None and _needs_passphrase(e):
|
|
459
|
+
passphrase = _prompt(f"Enter passphrase for key '{key}': ")
|
|
460
|
+
if passphrase is None:
|
|
461
|
+
raise AuthError("no authentication method") from e
|
|
462
|
+
continue
|
|
463
|
+
if say:
|
|
464
|
+
print(f"ccssh: identity {key} rejected ({e}), trying next", file=sys.stderr)
|
|
465
|
+
passphrase = None
|
|
466
|
+
break
|
|
467
|
+
except (OSError, ValueError) as e:
|
|
468
|
+
# Unreadable key file, FIDO2 token missing, unsupported key
|
|
469
|
+
# type: never abort the whole login over one -i (ssh warns
|
|
470
|
+
# and moves on to the next method).
|
|
471
|
+
last = e
|
|
472
|
+
try:
|
|
473
|
+
c.close()
|
|
474
|
+
except Exception:
|
|
475
|
+
pass
|
|
476
|
+
if s is not None:
|
|
477
|
+
try:
|
|
478
|
+
s.close()
|
|
479
|
+
except Exception:
|
|
480
|
+
pass
|
|
481
|
+
if say:
|
|
482
|
+
print(f"ccssh: identity {key} unusable ({e}), trying next", file=sys.stderr)
|
|
483
|
+
passphrase = None
|
|
484
|
+
break
|
|
485
|
+
pw = password
|
|
486
|
+
# Fail fast on dead servers: check TCP reachability BEFORE prompting.
|
|
487
|
+
# (Key attempts dial inside connect() and already fail fast; the
|
|
488
|
+
# password path used to prompt first and report 'refused' afterwards.)
|
|
489
|
+
if pw is None and prompt and sock_factory is None and not key_attempts:
|
|
490
|
+
_preflight_tcp(host, port, family, client_opts.get("timeout", 10.0))
|
|
491
|
+
|
|
492
|
+
def _kbdint_responder(prompts):
|
|
493
|
+
# ssh-like: zero prompts are answered silently; each real prompt
|
|
494
|
+
# is shown verbatim (no added prefix). Aborting raises out to
|
|
495
|
+
# stop the whole login flow immediately.
|
|
496
|
+
answers = []
|
|
497
|
+
for text, echo in prompts:
|
|
498
|
+
if not prompt:
|
|
499
|
+
return []
|
|
500
|
+
try:
|
|
501
|
+
if echo:
|
|
502
|
+
ans = input(text or "(server prompt) ")
|
|
503
|
+
else:
|
|
504
|
+
ans = _prompt(text or "(server prompt) ")
|
|
505
|
+
except (EOFError, KeyboardInterrupt):
|
|
506
|
+
ans = None
|
|
507
|
+
if ans is None:
|
|
508
|
+
raise _KbdintAbort()
|
|
509
|
+
answers.append(ans)
|
|
510
|
+
return answers
|
|
511
|
+
|
|
512
|
+
# Keyboard-interactive FIRST (like ssh): servers with zero prompts
|
|
513
|
+
# (e.g. serveo.net free tier) authenticate with no questions at all.
|
|
514
|
+
c = CCSSHClient(**client_opts)
|
|
515
|
+
s = sock_factory() if sock_factory else None
|
|
516
|
+
if say:
|
|
517
|
+
print(f"debug1: trying keyboard-interactive auth as {username}", file=sys.stderr)
|
|
518
|
+
try:
|
|
519
|
+
c.connect(host, port=port, username=username, password=None,
|
|
520
|
+
key_filename=None, cert_filename=None, family=family, sock=s,
|
|
521
|
+
kbdint_responder=_kbdint_responder)
|
|
522
|
+
if say:
|
|
523
|
+
print(f"debug1: keyboard-interactive auth accepted", file=sys.stderr)
|
|
524
|
+
return c
|
|
525
|
+
except _KbdintAbort:
|
|
526
|
+
try:
|
|
527
|
+
c.close()
|
|
528
|
+
except Exception:
|
|
529
|
+
pass
|
|
530
|
+
if s is not None:
|
|
531
|
+
try:
|
|
532
|
+
s.close()
|
|
533
|
+
except Exception:
|
|
534
|
+
pass
|
|
535
|
+
raise AuthError("no authentication method (aborted during keyboard-interactive)")
|
|
536
|
+
except AuthError as e:
|
|
537
|
+
last = e
|
|
538
|
+
try:
|
|
539
|
+
c.close()
|
|
540
|
+
except Exception:
|
|
541
|
+
pass
|
|
542
|
+
if s is not None:
|
|
543
|
+
try:
|
|
544
|
+
s.close()
|
|
545
|
+
except Exception:
|
|
546
|
+
pass
|
|
547
|
+
if say:
|
|
548
|
+
print(f"debug1: keyboard-interactive not accepted ({e})", file=sys.stderr)
|
|
549
|
+
tried_password = False
|
|
550
|
+
for _ in range(3 if prompt else 1):
|
|
551
|
+
if pw is None and prompt:
|
|
552
|
+
pw = _prompt(f"{username}@{host}'s password: ")
|
|
553
|
+
if pw is None:
|
|
554
|
+
break
|
|
555
|
+
c = CCSSHClient(**client_opts)
|
|
556
|
+
s = sock_factory() if sock_factory else None
|
|
557
|
+
if say:
|
|
558
|
+
print(f"debug1: trying password auth as {username}", file=sys.stderr)
|
|
559
|
+
try:
|
|
560
|
+
c.connect(host, port=port, username=username, password=pw, key_filename=None, cert_filename=None, family=family, sock=s)
|
|
561
|
+
if say:
|
|
562
|
+
print(f"debug1: password auth accepted", file=sys.stderr)
|
|
563
|
+
return c
|
|
564
|
+
except AuthError as e:
|
|
565
|
+
last = e
|
|
566
|
+
tried_password = tried_password or pw is not None
|
|
567
|
+
try:
|
|
568
|
+
c.close()
|
|
569
|
+
except Exception:
|
|
570
|
+
pass
|
|
571
|
+
if s is not None:
|
|
572
|
+
try:
|
|
573
|
+
s.close()
|
|
574
|
+
except Exception:
|
|
575
|
+
pass
|
|
576
|
+
if not prompt:
|
|
577
|
+
break
|
|
578
|
+
if say:
|
|
579
|
+
print("ccssh: password rejected, trying again", file=sys.stderr)
|
|
580
|
+
pw = None # force a fresh prompt
|
|
581
|
+
continue
|
|
582
|
+
if not tried_password and pw is None and last is not None and "publickey" in str(last):
|
|
583
|
+
# The user aborted before any password attempt: report that, not a
|
|
584
|
+
# stale key-phase error.
|
|
585
|
+
raise AuthError("no authentication method (aborted before password auth)")
|
|
586
|
+
raise last if last is not None else AuthError("no authentication method")
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _report_connect_error(e: BaseException) -> None:
|
|
590
|
+
"""
|
|
591
|
+
Print a connection failure. A changed host key gets the big
|
|
592
|
+
OpenSSH-style REMOTE HOST IDENTIFICATION banner (with the offending
|
|
593
|
+
file:line and both fingerprints, which travel inside the message).
|
|
594
|
+
"""
|
|
595
|
+
msg = str(e)
|
|
596
|
+
if "HOST KEY CHANGED" in msg:
|
|
597
|
+
bar = "@" * 60
|
|
598
|
+
print(bar, file=sys.stderr)
|
|
599
|
+
print("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @", file=sys.stderr)
|
|
600
|
+
print(bar, file=sys.stderr)
|
|
601
|
+
print("IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!", file=sys.stderr)
|
|
602
|
+
print("The host key of this server changed since the last connection.", file=sys.stderr)
|
|
603
|
+
print("This may mean a man-in-the-middle attack, a reinstalled server,", file=sys.stderr)
|
|
604
|
+
print("or (rarely) a legitimate key rotation. Do NOT accept it blindly.", file=sys.stderr)
|
|
605
|
+
print(f"ccssh: connect: {msg}", file=sys.stderr)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def main(argv: list[str] | None = None) -> int:
|
|
609
|
+
from ccssh import algs
|
|
610
|
+
opt_tokens, dest, command = split_argv(argv if argv is not None else sys.argv[1:])
|
|
611
|
+
rest: list[str] = list(opt_tokens)
|
|
612
|
+
if dest is not None:
|
|
613
|
+
# Guard a dashy destination (only reachable via `--`) from
|
|
614
|
+
# being re-parsed as an option by argparse.
|
|
615
|
+
rest += ["--", dest] if dest.startswith("-") else [dest]
|
|
616
|
+
try:
|
|
617
|
+
# Strict: unknown options fail here (exit 255) instead of being
|
|
618
|
+
# misparsed as destinations/commands downstream.
|
|
619
|
+
args = build_parser().parse_args(rest)
|
|
620
|
+
except UsageError as e:
|
|
621
|
+
print(f"ccssh: {e}", file=sys.stderr)
|
|
622
|
+
return 255
|
|
623
|
+
args.command = command
|
|
624
|
+
try:
|
|
625
|
+
return _main_inner(args, opt_tokens)
|
|
626
|
+
except UsageError as e:
|
|
627
|
+
print(f"ccssh: {e}", file=sys.stderr)
|
|
628
|
+
return 255
|
|
629
|
+
except BrokenPipeError:
|
|
630
|
+
return 141 # dying of SIGPIPE anywhere (like `... | head`)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _main_inner(args, opt_tokens: list[str]) -> int:
|
|
634
|
+
from ccssh import algs
|
|
635
|
+
# -t/-T: last one wins (like ssh), not a conflict.
|
|
636
|
+
for tok in opt_tokens:
|
|
637
|
+
if tok == "-t":
|
|
638
|
+
args.force_tty, args.no_pty = True, False
|
|
639
|
+
elif tok == "-T":
|
|
640
|
+
args.force_tty, args.no_pty = False, True
|
|
641
|
+
if args.version:
|
|
642
|
+
from ccssh import __version__
|
|
643
|
+
try:
|
|
644
|
+
print(f"ccssh {__version__}", file=sys.stderr)
|
|
645
|
+
except BrokenPipeError:
|
|
646
|
+
return 141
|
|
647
|
+
return 0
|
|
648
|
+
if args.query:
|
|
649
|
+
try:
|
|
650
|
+
_run_query(args.query)
|
|
651
|
+
except UsageError as e:
|
|
652
|
+
print(f"ccssh: {e}", file=sys.stderr)
|
|
653
|
+
return 255
|
|
654
|
+
except BrokenPipeError:
|
|
655
|
+
return 141
|
|
656
|
+
return 0
|
|
657
|
+
if not args.destination:
|
|
658
|
+
print("ccssh: need destination [user@]hostname", file=sys.stderr)
|
|
659
|
+
return 255
|
|
660
|
+
if not _valid_port(args.port):
|
|
661
|
+
print(f"ccssh: bad port {args.port!r} (must be 1..65535)", file=sys.stderr)
|
|
662
|
+
return 255
|
|
663
|
+
host, user = parse_destination(args.destination, args.login)
|
|
664
|
+
if not host or any(c.isspace() or ord(c) < 32 for c in host):
|
|
665
|
+
print(f"ccssh: bad destination {args.destination!r}", file=sys.stderr)
|
|
666
|
+
return 255
|
|
667
|
+
if not user or any(c.isspace() or ord(c) < 32 for c in user):
|
|
668
|
+
print(f"ccssh: no username for {host!r} (use user@host or -l login)",
|
|
669
|
+
file=sys.stderr)
|
|
670
|
+
return 255
|
|
671
|
+
o_opts = parse_o_options(args.options)
|
|
672
|
+
_check_known_options(o_opts)
|
|
673
|
+
# -o wins, then -c/-m flags, then built-in defaults.
|
|
674
|
+
# Comma lists replace; +alg/-alg/^alg are relative to the defaults.
|
|
675
|
+
kex = algs.expand_alg_option(algs.SUPPORTED_KEX, o_opts.get("kexalgorithms", ",".join(algs.SUPPORTED_KEX)))
|
|
676
|
+
ciphers = algs.expand_alg_option(algs.SUPPORTED_CIPHERS, o_opts.get("ciphers") or args.cipher_spec or ",".join(algs.SUPPORTED_CIPHERS))
|
|
677
|
+
macs = algs.expand_alg_option(algs.SUPPORTED_MACS, o_opts.get("macs") or args.mac_spec or ",".join(algs.SUPPORTED_MACS))
|
|
678
|
+
hostkeys = algs.expand_alg_option(algs.SUPPORTED_HOSTKEYS, o_opts.get("hostkeyalgorithms", ",".join(algs.SUPPORTED_HOSTKEYS)))
|
|
679
|
+
try:
|
|
680
|
+
timeout = float(o_opts.get("connecttimeout", args.timeout))
|
|
681
|
+
except ValueError:
|
|
682
|
+
print(f"ccssh: bad ConnectTimeout {o_opts.get('connecttimeout')!r}", file=sys.stderr)
|
|
683
|
+
return 255
|
|
684
|
+
if not (isinstance(timeout, float) and 0 < timeout <= 600):
|
|
685
|
+
print(f"ccssh: bad ConnectTimeout {timeout!r} (must be 0..600s)", file=sys.stderr)
|
|
686
|
+
return 255
|
|
687
|
+
_policy_raw = o_opts.get("stricthostkeychecking", args.host_policy)
|
|
688
|
+
_policy_map = {"yes": "strict", "strict": "strict", "ask": "ask",
|
|
689
|
+
"accept-new": "accept-new", "no": "off", "off": "off"}
|
|
690
|
+
_norm = str(_policy_raw).lower()
|
|
691
|
+
if _norm not in _policy_map:
|
|
692
|
+
print(f"ccssh: bad StrictHostKeyChecking {_policy_raw!r} "
|
|
693
|
+
f"(must be yes|ask|accept-new|no)", file=sys.stderr)
|
|
694
|
+
return 255
|
|
695
|
+
policy = _policy_map[_norm]
|
|
696
|
+
known = o_opts.get("userknownhostsfile", args.known_hosts)
|
|
697
|
+
if known is not None:
|
|
698
|
+
known = os.path.expandvars(os.path.expanduser(known))
|
|
699
|
+
if not known.strip():
|
|
700
|
+
print("ccssh: bad UserKnownHostsFile (empty)", file=sys.stderr)
|
|
701
|
+
return 255
|
|
702
|
+
if args.password_env and not args.password_env.isidentifier():
|
|
703
|
+
print(f"ccssh: bad --password-env {args.password_env!r}", file=sys.stderr)
|
|
704
|
+
return 255
|
|
705
|
+
password = os.environ.get(args.password_env) if args.password_env else None
|
|
706
|
+
if args.password_env and not args.q:
|
|
707
|
+
print("ccssh: warning: --password-env leaks via process environ; "
|
|
708
|
+
"prefer interactive prompt", file=sys.stderr)
|
|
709
|
+
if policy == "off" and not args.q:
|
|
710
|
+
print("ccssh: WARNING: host key verification is OFF (MitM possible)",
|
|
711
|
+
file=sys.stderr)
|
|
712
|
+
say = args.v >= 1 and not args.q
|
|
713
|
+
say2 = args.v >= 2 and not args.q
|
|
714
|
+
# -t/-T: last one wins (like ssh), never a conflict. The raw token
|
|
715
|
+
# stream preserves order; argparse booleans cannot.
|
|
716
|
+
for _tok in opt_tokens:
|
|
717
|
+
if _tok == "-t":
|
|
718
|
+
args.force_tty, args.no_pty = True, False
|
|
719
|
+
elif _tok == "-T":
|
|
720
|
+
args.force_tty, args.no_pty = False, True
|
|
721
|
+
# OpenSSH-style negotiation summary. -v: one line + policy warning.
|
|
722
|
+
# -vv: full offers, timeouts, jump info (client._log adds per-step debug).
|
|
723
|
+
if say:
|
|
724
|
+
print(f"debug1: Connecting to {host} [{args.port}] as {user}", file=sys.stderr)
|
|
725
|
+
print(f"debug1: kex {kex[0]} cipher {ciphers[0]} mac {macs[0]} hostkey {hostkeys[0]} policy {policy}", file=sys.stderr)
|
|
726
|
+
if say2:
|
|
727
|
+
print(f"debug2: KEX offer: {','.join(kex)}", file=sys.stderr)
|
|
728
|
+
print(f"debug2: cipher offer: {','.join(ciphers)}", file=sys.stderr)
|
|
729
|
+
print(f"debug2: mac offer: {','.join(macs)}", file=sys.stderr)
|
|
730
|
+
print(f"debug2: hostkey offer: {','.join(hostkeys)}", file=sys.stderr)
|
|
731
|
+
print(f"debug2: timeout={timeout}s known_hosts={known or '~/.ssh/known_hosts'}", file=sys.stderr)
|
|
732
|
+
if args.local_fwd and args.remote_fwd:
|
|
733
|
+
print("ccssh: -L and -R cannot share one connection "
|
|
734
|
+
"(one forwarder per connection)", file=sys.stderr)
|
|
735
|
+
return 255
|
|
736
|
+
if args.N and args.command:
|
|
737
|
+
print("ccssh: cannot execute command with -N", file=sys.stderr)
|
|
738
|
+
return 255
|
|
739
|
+
is_tty = sys.stdin.isatty()
|
|
740
|
+
# ask policy needs a prompt hook (tty only; scripts fail closed).
|
|
741
|
+
ask_cb = _make_ask_callback() if (policy == "ask" and is_tty) else None
|
|
742
|
+
client_opts = dict(timeout=timeout, known_hosts=known, host_policy=policy, kex=kex, ciphers=ciphers, macs=macs, hostkeys=hostkeys, verbose=args.v if not args.q else 0, ask_callback=ask_cb)
|
|
743
|
+
# Like OpenSSH: with no -i, offer the default ~/.ssh keys first and
|
|
744
|
+
# only then fall back to the password prompt.
|
|
745
|
+
identities = args.identity or default_identities()
|
|
746
|
+
if say2 and identities != args.identity:
|
|
747
|
+
print(f"debug2: default identities: {','.join(identities) or '(none found)'}",
|
|
748
|
+
file=sys.stderr)
|
|
749
|
+
if say2 and identities != args.identity:
|
|
750
|
+
print(f"debug2: default identities: {','.join(identities) or '(none found)'}",
|
|
751
|
+
file=sys.stderr)
|
|
752
|
+
jump_clients: list = []
|
|
753
|
+
sock_factory = None
|
|
754
|
+
if args.jump:
|
|
755
|
+
hops = parse_jump_chain(args.jump, user)
|
|
756
|
+
prev = None
|
|
757
|
+
try:
|
|
758
|
+
for jhost, juser, jport in hops:
|
|
759
|
+
if say:
|
|
760
|
+
print(f"debug1: via jump {juser}@{jhost}:{jport}", file=sys.stderr)
|
|
761
|
+
if prev is None:
|
|
762
|
+
hop_factory = None
|
|
763
|
+
else:
|
|
764
|
+
_prev = prev
|
|
765
|
+
_jh, _jp = jhost, jport
|
|
766
|
+
hop_factory = lambda _p=_prev, _h=_jh, _pt=_jp: _p.open_channel_socket(_h, _pt)
|
|
767
|
+
jc = _connect_first_working(client_opts, jhost, port=jport, username=juser, password=password, identities=identities, cert=args.cert, verbose=args.v, prompt=is_tty, family=args.family, quiet=bool(args.q), sock_factory=hop_factory)
|
|
768
|
+
jump_clients.append(jc)
|
|
769
|
+
prev = jc
|
|
770
|
+
except Exception as e:
|
|
771
|
+
for jc in jump_clients:
|
|
772
|
+
try:
|
|
773
|
+
jc.close()
|
|
774
|
+
except Exception:
|
|
775
|
+
pass
|
|
776
|
+
print(f"ccssh: jump: {e}", file=sys.stderr)
|
|
777
|
+
return 255
|
|
778
|
+
jump_client = prev
|
|
779
|
+
sock_factory = lambda: jump_client.open_channel_socket(host, args.port)
|
|
780
|
+
try:
|
|
781
|
+
c = _connect_first_working(client_opts, host, port=args.port, username=user, password=password, identities=identities, cert=args.cert, verbose=args.v, prompt=is_tty, family=args.family, quiet=bool(args.q), sock_factory=sock_factory)
|
|
782
|
+
except AuthError as e:
|
|
783
|
+
for _jc in jump_clients:
|
|
784
|
+
try:
|
|
785
|
+
_jc.close()
|
|
786
|
+
except Exception:
|
|
787
|
+
pass
|
|
788
|
+
print(f"ccssh: Permission denied: {e}", file=sys.stderr)
|
|
789
|
+
return 255
|
|
790
|
+
except Exception as e:
|
|
791
|
+
for _jc in jump_clients:
|
|
792
|
+
try:
|
|
793
|
+
_jc.close()
|
|
794
|
+
except Exception:
|
|
795
|
+
pass
|
|
796
|
+
_report_connect_error(e)
|
|
797
|
+
return 255
|
|
798
|
+
active = []
|
|
799
|
+
try:
|
|
800
|
+
for spec in args.local_fwd:
|
|
801
|
+
lport, rhost, rport, bind = _parse_L(spec)
|
|
802
|
+
fwd = c.forward_local(lport, rhost, rport, bind=bind)
|
|
803
|
+
active.append(fwd)
|
|
804
|
+
if say:
|
|
805
|
+
print(f"debug1: -L {bind}:{fwd.port}:{rhost}:{rport}", file=sys.stderr)
|
|
806
|
+
for spec in args.remote_fwd:
|
|
807
|
+
sport, rhost, rport, bind = _parse_L(spec, default_bind="localhost")
|
|
808
|
+
fwd = c.forward_remote(sport, rhost, rport, bind=bind)
|
|
809
|
+
active.append(fwd)
|
|
810
|
+
if say:
|
|
811
|
+
print(f"debug1: -R {bind}:{fwd.port}:{rhost}:{rport}", file=sys.stderr)
|
|
812
|
+
except Exception as e:
|
|
813
|
+
print(f"ccssh: forward: {e}", file=sys.stderr)
|
|
814
|
+
c.close()
|
|
815
|
+
for _jc in jump_clients:
|
|
816
|
+
try:
|
|
817
|
+
_jc.close()
|
|
818
|
+
except Exception:
|
|
819
|
+
pass
|
|
820
|
+
return 255
|
|
821
|
+
if args.N:
|
|
822
|
+
if say:
|
|
823
|
+
neg = c.negotiated()
|
|
824
|
+
print(f"debug1: connected cipher={neg['cipher']} kex={neg['kex']} mac={neg['mac']} strict={neg['strict']}", file=sys.stderr)
|
|
825
|
+
if active:
|
|
826
|
+
print("ccssh: forwards active, Ctrl-C to exit", file=sys.stderr)
|
|
827
|
+
try:
|
|
828
|
+
while True:
|
|
829
|
+
time.sleep(1)
|
|
830
|
+
except KeyboardInterrupt:
|
|
831
|
+
pass
|
|
832
|
+
c.close()
|
|
833
|
+
for _jc in jump_clients:
|
|
834
|
+
try:
|
|
835
|
+
_jc.close()
|
|
836
|
+
except Exception:
|
|
837
|
+
pass
|
|
838
|
+
return 0
|
|
839
|
+
devnull = open(os.devnull, "rb") if args.stdin_null else None
|
|
840
|
+
stdin_fd = devnull.fileno() if devnull is not None else None
|
|
841
|
+
from ccssh import shell as _shell_mod
|
|
842
|
+
|
|
843
|
+
def _run_shell(**kw) -> int:
|
|
844
|
+
try:
|
|
845
|
+
return _shell_mod.run_interactive(c, stdin_fd=stdin_fd, **kw)
|
|
846
|
+
except Exception as e:
|
|
847
|
+
print(f"ccssh: shell: {e}", file=sys.stderr)
|
|
848
|
+
return 255
|
|
849
|
+
finally:
|
|
850
|
+
c.close()
|
|
851
|
+
for _jc in jump_clients:
|
|
852
|
+
try:
|
|
853
|
+
_jc.close()
|
|
854
|
+
except Exception:
|
|
855
|
+
pass
|
|
856
|
+
if devnull is not None:
|
|
857
|
+
devnull.close()
|
|
858
|
+
|
|
859
|
+
if args.command:
|
|
860
|
+
if args.force_tty: # -t: pty exec, output streams over the terminal
|
|
861
|
+
return _run_shell(command=" ".join(args.command), pty=True)
|
|
862
|
+
cmd = " ".join(args.command)
|
|
863
|
+
try:
|
|
864
|
+
out, err, code = c.exec_command(cmd, raw_bytes=True)
|
|
865
|
+
except Exception as e:
|
|
866
|
+
print(f"ccssh: exec: {e}", file=sys.stderr)
|
|
867
|
+
return 255
|
|
868
|
+
finally:
|
|
869
|
+
c.close()
|
|
870
|
+
for _jc in jump_clients:
|
|
871
|
+
try:
|
|
872
|
+
_jc.close()
|
|
873
|
+
except Exception:
|
|
874
|
+
pass
|
|
875
|
+
if devnull is not None:
|
|
876
|
+
try:
|
|
877
|
+
devnull.close()
|
|
878
|
+
except Exception:
|
|
879
|
+
pass
|
|
880
|
+
try:
|
|
881
|
+
sys.stdout.buffer.write(out)
|
|
882
|
+
sys.stderr.buffer.write(err)
|
|
883
|
+
sys.stdout.buffer.flush()
|
|
884
|
+
sys.stderr.buffer.flush()
|
|
885
|
+
except BrokenPipeError:
|
|
886
|
+
return 141 # like dying of SIGPIPE under `... | head`
|
|
887
|
+
return code
|
|
888
|
+
# No command and no -N: interactive shell, like `ssh host`.
|
|
889
|
+
return _run_shell(pty=not args.no_pty)
|