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/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)