exec-helpers 8.2.0__tar.gz → 8.2.1__tar.gz
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.
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/.pre-commit-config.yaml +2 -2
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/PKG-INFO +6 -2
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/README.rst +5 -1
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/__init__.py +2 -3
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/_helpers.py +40 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/_ssh_base.py +108 -24
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/_ssh_helpers.py +96 -1
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/_subprocess_helpers.py +3 -2
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/_version.py +3 -3
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/api.py +31 -8
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/async_api/api.py +30 -6
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/async_api/exec_result.py +18 -6
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/async_api/subprocess.py +106 -28
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/exec_result.py +18 -18
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/ssh_auth.py +42 -29
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/subprocess.py +81 -18
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers.egg-info/PKG-INFO +6 -2
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers.egg-info/SOURCES.txt +2 -0
- exec_helpers-8.2.1/exec_helpers.egg-info/scm_file_list.json +77 -0
- exec_helpers-8.2.1/exec_helpers.egg-info/scm_version.json +8 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/pyproject.toml +116 -97
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/.editorconfig +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/.readthedocs.yaml +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/CI_REQUIREMENTS.txt +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/LICENSE +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/MANIFEST.in +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/build_requirements.txt +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/classifiers.txt +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/__init__.pyi +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/_log_templates.py +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/async_api/__init__.py +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/async_api/__init__.pyi +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/constants.py +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/exceptions.py +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/proc_enums.py +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/py.typed +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers/ssh.py +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers.egg-info/dependency_links.txt +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers.egg-info/not-zip-safe +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers.egg-info/requires.txt +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/exec_helpers.egg-info/top_level.txt +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/requirements.txt +0 -0
- {exec_helpers-8.2.0 → exec_helpers-8.2.1}/setup.cfg +0 -0
|
@@ -10,7 +10,7 @@ repos:
|
|
|
10
10
|
|
|
11
11
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
12
12
|
# Ruff version.
|
|
13
|
-
rev: v0.
|
|
13
|
+
rev: v0.17.3
|
|
14
14
|
hooks:
|
|
15
15
|
# Run the linter.
|
|
16
16
|
- id: ruff-check
|
|
@@ -29,6 +29,6 @@ repos:
|
|
|
29
29
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
|
30
30
|
# Use the sha / tag you want to point at
|
|
31
31
|
# or use `pre-commit autoupdate` to get the latest version
|
|
32
|
-
rev: "v2.
|
|
32
|
+
rev: "v2.30.3"
|
|
33
33
|
hooks:
|
|
34
34
|
- id: pyproject-fmt
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: exec-helpers
|
|
3
|
-
Version: 8.2.
|
|
3
|
+
Version: 8.2.1
|
|
4
4
|
Summary: Execution helpers for simplified usage of subprocess and ssh.
|
|
5
5
|
Author-email: Aleksei Stepanov <penguinolog@gmail.com>
|
|
6
6
|
Maintainer-email: Aleksei Stepanov <penguinolog@gmail.com>, Antonio Esposito <esposito.cloud@gmail.com>, Dennis Dmitriev <dis-xcom@gmail.com>
|
|
@@ -445,7 +445,11 @@ Keyword arguments:
|
|
|
445
445
|
- cwd - working directory.
|
|
446
446
|
- env - environment variables dict.
|
|
447
447
|
|
|
448
|
-
.. note::
|
|
448
|
+
.. note:: The command is executed by the shell by default.
|
|
449
|
+
`Subprocess(shell=False)` (and `async_api.Subprocess(shell=False)`) executes it directly instead:
|
|
450
|
+
the command must be a list of arguments (a string raises `TypeError`),
|
|
451
|
+
and no shell syntax, expansion or `sh -c` inside a chroot is involved.
|
|
452
|
+
`SSHClient` can not do this: the remote side always passes the command to a shell.
|
|
449
453
|
|
|
450
454
|
async_api.Subprocess specific
|
|
451
455
|
-----------------------------
|
|
@@ -403,7 +403,11 @@ Keyword arguments:
|
|
|
403
403
|
- cwd - working directory.
|
|
404
404
|
- env - environment variables dict.
|
|
405
405
|
|
|
406
|
-
.. note::
|
|
406
|
+
.. note:: The command is executed by the shell by default.
|
|
407
|
+
`Subprocess(shell=False)` (and `async_api.Subprocess(shell=False)`) executes it directly instead:
|
|
408
|
+
the command must be a list of arguments (a string raises `TypeError`),
|
|
409
|
+
and no shell syntax, expansion or `sh -c` inside a chroot is involved.
|
|
410
|
+
`SSHClient` can not do this: the remote side always passes the command to a shell.
|
|
407
411
|
|
|
408
412
|
async_api.Subprocess specific
|
|
409
413
|
-----------------------------
|
|
@@ -16,14 +16,13 @@
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import contextlib
|
|
19
20
|
import importlib
|
|
20
21
|
import typing
|
|
21
22
|
import warnings
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
with contextlib.suppress(ImportError):
|
|
24
25
|
from ._version import version as __version__ # noqa: F401
|
|
25
|
-
except ImportError:
|
|
26
|
-
pass
|
|
27
26
|
|
|
28
27
|
if typing.TYPE_CHECKING:
|
|
29
28
|
from collections.abc import Sequence
|
|
@@ -9,6 +9,7 @@ import typing
|
|
|
9
9
|
|
|
10
10
|
if typing.TYPE_CHECKING:
|
|
11
11
|
from collections.abc import Iterable
|
|
12
|
+
from collections.abc import Mapping
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def string_bytes_bytearray_as_bytes(src: str | bytes | bytearray) -> bytes:
|
|
@@ -96,3 +97,42 @@ def chroot_command(command: str, chroot_path: str | None = None, chroot_exe: str
|
|
|
96
97
|
quoted_command = shlex.quote(command)
|
|
97
98
|
return f"{chroot_exe} {chroot_dst} sh -c {shlex.quote(f'eval {quoted_command}')}"
|
|
98
99
|
return command
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def chroot_argv_command(command: str, chroot_path: str | None = None, chroot_exe: str | None = None) -> str:
|
|
103
|
+
"""Prepare command for chroot execution without a shell, only the arguments are joined.
|
|
104
|
+
|
|
105
|
+
:param command: Original command, a result of `shlex.join`.
|
|
106
|
+
:type command: str
|
|
107
|
+
:param chroot_path: chroot path.
|
|
108
|
+
:type chroot_path: str | None
|
|
109
|
+
:param chroot_exe: chroot executable, may carry its own arguments.
|
|
110
|
+
:type chroot_exe: str | None
|
|
111
|
+
:return: Command to be executed with chroot rules if applicable.
|
|
112
|
+
:rtype: str
|
|
113
|
+
"""
|
|
114
|
+
if chroot_path and chroot_path != "/":
|
|
115
|
+
return shlex.join([*shlex.split(chroot_exe or "chroot"), chroot_path.strip(), *shlex.split(command)])
|
|
116
|
+
return command
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def repr_stdin(stdin: bytes | None) -> str:
|
|
120
|
+
"""Describe STDIN data for a debug string without exposing it.
|
|
121
|
+
|
|
122
|
+
:param stdin: Data for STDIN.
|
|
123
|
+
:type stdin: bytes | None
|
|
124
|
+
:return: Size of the data, as the content can hold secrets.
|
|
125
|
+
:rtype: str
|
|
126
|
+
"""
|
|
127
|
+
return "None" if stdin is None else f"<{len(stdin)} bytes>"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def repr_env(env: Mapping[str, str] | None) -> str:
|
|
131
|
+
"""Describe an environment for a debug string without exposing the values.
|
|
132
|
+
|
|
133
|
+
:param env: Process environment.
|
|
134
|
+
:type env: Mapping[str, str] | None
|
|
135
|
+
:return: Variable names only, as the values can hold secrets.
|
|
136
|
+
:rtype: str
|
|
137
|
+
"""
|
|
138
|
+
return "None" if env is None else f"<names: {sorted(env, key=repr)}>"
|
|
@@ -21,7 +21,7 @@ from __future__ import annotations
|
|
|
21
21
|
import concurrent.futures
|
|
22
22
|
import contextlib
|
|
23
23
|
import copy
|
|
24
|
-
import datetime
|
|
24
|
+
import datetime as dt
|
|
25
25
|
import getpass
|
|
26
26
|
import logging
|
|
27
27
|
import pathlib
|
|
@@ -94,6 +94,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
|
|
|
94
94
|
__slots__ = ()
|
|
95
95
|
|
|
96
96
|
@property
|
|
97
|
+
# pyrefly: ignore[bad-override] # the property narrows the NamedTuple field type
|
|
97
98
|
def interface(self) -> paramiko.Channel:
|
|
98
99
|
"""Override original NamedTuple with proper typing.
|
|
99
100
|
|
|
@@ -103,6 +104,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
|
|
|
103
104
|
return super().interface # type: ignore[no-any-return]
|
|
104
105
|
|
|
105
106
|
@property
|
|
107
|
+
# pyrefly: ignore[bad-override] # the property narrows the NamedTuple field type
|
|
106
108
|
def stdin(self) -> paramiko.channel.ChannelStdinFile:
|
|
107
109
|
"""Override original NamedTuple with proper typing.
|
|
108
110
|
|
|
@@ -117,6 +119,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
|
|
|
117
119
|
return super().stdin # type: ignore[return-value]
|
|
118
120
|
|
|
119
121
|
@property
|
|
122
|
+
# pyrefly: ignore[bad-override] # the property narrows the NamedTuple field type
|
|
120
123
|
def stderr(self) -> paramiko.channel.ChannelStderrFile | None:
|
|
121
124
|
"""Override original NamedTuple with proper typing.
|
|
122
125
|
|
|
@@ -126,6 +129,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
|
|
|
126
129
|
return super().stderr
|
|
127
130
|
|
|
128
131
|
@property
|
|
132
|
+
# pyrefly: ignore[bad-override] # the property narrows the NamedTuple field type
|
|
129
133
|
def stdout(self) -> paramiko.channel.ChannelFile | None:
|
|
130
134
|
"""Override original NamedTuple with proper typing.
|
|
131
135
|
|
|
@@ -135,7 +139,7 @@ class SshExecuteAsyncResult(api.ExecuteAsyncResult):
|
|
|
135
139
|
return super().stdout
|
|
136
140
|
|
|
137
141
|
|
|
138
|
-
class _SSHExecuteContext(api.ExecuteContext
|
|
142
|
+
class _SSHExecuteContext(api.ExecuteContext):
|
|
139
143
|
"""SSH Execute context."""
|
|
140
144
|
|
|
141
145
|
__slots__ = (
|
|
@@ -222,8 +226,8 @@ class _SSHExecuteContext(api.ExecuteContext, contextlib.AbstractContextManager[S
|
|
|
222
226
|
"""
|
|
223
227
|
return (
|
|
224
228
|
f"<SSHClient().open_execute_context("
|
|
225
|
-
f"command={self.
|
|
226
|
-
f"stdin={self.stdin
|
|
229
|
+
f"command={self._masked_command!r}, "
|
|
230
|
+
f"stdin={_helpers.repr_stdin(self.stdin)}, "
|
|
227
231
|
f"open_stdout={self.open_stdout!r}, "
|
|
228
232
|
f"open_stderr={self.open_stderr!r}, "
|
|
229
233
|
f"get_pty={self.__get_pty!r}, "
|
|
@@ -308,7 +312,7 @@ class _SSHExecuteContext(api.ExecuteContext, contextlib.AbstractContextManager[S
|
|
|
308
312
|
stderr = None
|
|
309
313
|
_stdin: paramiko.channel.ChannelFile
|
|
310
314
|
with chan.makefile("wb") as _stdin:
|
|
311
|
-
started =
|
|
315
|
+
started = dt.datetime.now(tz=dt.UTC)
|
|
312
316
|
if self.__sudo_mode:
|
|
313
317
|
chan.exec_command(self.command) # nosec # Sanitize on caller side
|
|
314
318
|
if not stdout.channel.closed:
|
|
@@ -333,13 +337,8 @@ class _SSHExecuteContext(api.ExecuteContext, contextlib.AbstractContextManager[S
|
|
|
333
337
|
res_stdout = None
|
|
334
338
|
|
|
335
339
|
# noinspection PyArgumentList
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
stdin=_stdin,
|
|
339
|
-
stderr=stderr,
|
|
340
|
-
stdout=res_stdout,
|
|
341
|
-
started=started,
|
|
342
|
-
)
|
|
340
|
+
# The fields are typed by properties of the subclass, which type checkers do not accept as constructor arguments
|
|
341
|
+
return SshExecuteAsyncResult._make((chan, _stdin, stderr, res_stdout, started))
|
|
343
342
|
|
|
344
343
|
def __exit__(
|
|
345
344
|
self,
|
|
@@ -458,6 +457,13 @@ class SSHClientBase(api.ExecHelper):
|
|
|
458
457
|
:type allow_ssh_agent: bool
|
|
459
458
|
:param missing_host_key_policy: Missing host key policy class. See paramiko.MissingHostKeyPolicy for details.
|
|
460
459
|
:type missing_host_key_policy: type[paramiko.MissingHostKeyPolicy]
|
|
460
|
+
:param connect_timeout: Timeout in seconds for the TCP connection, the SSH banner, the key exchange and the
|
|
461
|
+
authentication of every connection step, including hosts reached through `proxy_to`.
|
|
462
|
+
An authentication timeout is reported as an authentication failure. By default: None
|
|
463
|
+
(paramiko defaults: no TCP and key exchange limit, banner 15 s, authentication 30 s). A connection attempt
|
|
464
|
+
is retried 3 times with a 3 s pause, so a hung host takes up to 3 times the timeout plus 6 s to report.
|
|
465
|
+
:type connect_timeout: float | None
|
|
466
|
+
:raises ValueError: connect_timeout is not positive.
|
|
461
467
|
|
|
462
468
|
.. note:: auth has priority over username/password/private_keys.
|
|
463
469
|
.. note::
|
|
@@ -477,12 +483,16 @@ class SSHClientBase(api.ExecHelper):
|
|
|
477
483
|
.. versionchanged:: 7.4.0 Return of keepalive_mode to prevent mix with a keepalive period. Default is `False`.
|
|
478
484
|
.. versionchanged:: 8.0.0 Expose SSH Agent usage override.
|
|
479
485
|
.. versionchanged:: 8.1.4 Allow specifying missing_host_key_policy.
|
|
486
|
+
.. versionchanged:: 8.2.1 Added connect_timeout.
|
|
487
|
+
.. versionchanged:: 8.2.1 Direct connection to the local host accepts the host keys of this machine without
|
|
488
|
+
the unknown host key warning; a key of another server is handled by the missing host key policy as before.
|
|
480
489
|
"""
|
|
481
490
|
|
|
482
491
|
__slots__ = (
|
|
483
492
|
"__allow_agent",
|
|
484
493
|
"__auth_mapping",
|
|
485
494
|
"__conn_chain",
|
|
495
|
+
"__connect_timeout",
|
|
486
496
|
"__hostname",
|
|
487
497
|
"__keepalive_mode",
|
|
488
498
|
"__keepalive_period",
|
|
@@ -519,14 +529,18 @@ class SSHClientBase(api.ExecHelper):
|
|
|
519
529
|
keepalive: KeepAlivePeriodT = 1,
|
|
520
530
|
allow_ssh_agent: bool = True,
|
|
521
531
|
missing_host_key_policy: type[paramiko.MissingHostKeyPolicy] = paramiko.WarningPolicy,
|
|
532
|
+
connect_timeout: float | None = None,
|
|
522
533
|
) -> None:
|
|
523
|
-
"""
|
|
534
|
+
"""Initialize the SSH client helper."""
|
|
524
535
|
self.__sudo_mode = False
|
|
525
536
|
self.__keepalive_period: int = int(keepalive)
|
|
526
537
|
self.__keepalive_mode = False
|
|
527
538
|
self.__verbose: bool = verbose
|
|
528
539
|
self.__sock = sock
|
|
529
540
|
self._missing_host_key_policy = missing_host_key_policy
|
|
541
|
+
if connect_timeout is not None and connect_timeout <= 0:
|
|
542
|
+
raise ValueError(f"connect_timeout should be positive, got {connect_timeout!r}")
|
|
543
|
+
self.__connect_timeout = connect_timeout
|
|
530
544
|
|
|
531
545
|
self.__ssh: paramiko.SSHClient
|
|
532
546
|
self.__sftp: paramiko.SFTPClient | None = None
|
|
@@ -749,14 +763,30 @@ class SSHClientBase(api.ExecHelper):
|
|
|
749
763
|
"""
|
|
750
764
|
return self.__ssh
|
|
751
765
|
|
|
766
|
+
def __connect(self) -> None:
|
|
767
|
+
"""Open the connection, with retries unless a socket was supplied.
|
|
768
|
+
|
|
769
|
+
A failed attempt closes the supplied socket, so there is nothing to retry on:
|
|
770
|
+
the next attempt would only fail on the closed socket and hide the original error.
|
|
771
|
+
"""
|
|
772
|
+
with self.lock:
|
|
773
|
+
if self.__sock is None:
|
|
774
|
+
self.__connect_with_retries()
|
|
775
|
+
else:
|
|
776
|
+
self.__connect_once()
|
|
777
|
+
|
|
752
778
|
@tenacity.retry(
|
|
753
779
|
retry=RetryOnExceptions(retry_on=paramiko.SSHException, reraise=paramiko.AuthenticationException),
|
|
754
780
|
stop=tenacity.stop.stop_after_attempt(3),
|
|
755
781
|
wait=tenacity.wait.wait_fixed(3),
|
|
756
782
|
reraise=True,
|
|
757
783
|
)
|
|
758
|
-
def
|
|
759
|
-
"""
|
|
784
|
+
def __connect_with_retries(self) -> None:
|
|
785
|
+
"""Open the connection, retry on the connection errors."""
|
|
786
|
+
self.__connect_once()
|
|
787
|
+
|
|
788
|
+
def __connect_once(self) -> None:
|
|
789
|
+
"""Open the connection, one attempt."""
|
|
760
790
|
with self.lock:
|
|
761
791
|
if self.__sock is not None:
|
|
762
792
|
self.__ssh = paramiko.SSHClient()
|
|
@@ -769,6 +799,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
769
799
|
log=self.__verbose,
|
|
770
800
|
sock=self.__sock,
|
|
771
801
|
allow_ssh_agent=self.allow_ssh_agent,
|
|
802
|
+
timeout=self.__connect_timeout,
|
|
772
803
|
)
|
|
773
804
|
else:
|
|
774
805
|
self.__ssh = self.__get_client()
|
|
@@ -776,6 +807,27 @@ class SSHClientBase(api.ExecHelper):
|
|
|
776
807
|
transport: paramiko.Transport = self._ssh_transport
|
|
777
808
|
transport.set_keepalive(1 if self.__keepalive_period else 0) # send keepalive packets
|
|
778
809
|
|
|
810
|
+
def __host_key_policy(self, hostname: str) -> paramiko.MissingHostKeyPolicy | type[paramiko.MissingHostKeyPolicy]:
|
|
811
|
+
"""Select the missing host key policy for a host which is connected to directly.
|
|
812
|
+
|
|
813
|
+
:param hostname: Host to connect to, without a proxy or a jump host.
|
|
814
|
+
:type hostname: str
|
|
815
|
+
:return: Configured policy; for the local host a policy, which accepts the keys of this machine first.
|
|
816
|
+
:rtype: paramiko.MissingHostKeyPolicy | type[paramiko.MissingHostKeyPolicy]
|
|
817
|
+
|
|
818
|
+
The local host is normally absent from `known_hosts`, so its own keys are the reference. A key of another
|
|
819
|
+
server (any local address can be a container, a tunnel or another server) goes to the configured policy as
|
|
820
|
+
before, so nothing which worked fails because of it. Without readable keys the configured policy decides.
|
|
821
|
+
The own key is accepted without asking the configured policy, so that policy does not see it.
|
|
822
|
+
"""
|
|
823
|
+
try:
|
|
824
|
+
keys = _ssh_helpers.local_host_keys() if _ssh_helpers.is_local_host(hostname) else ()
|
|
825
|
+
except OSError: # the answer only silences a warning, so a failure of the lookup must not fail the connection
|
|
826
|
+
keys = ()
|
|
827
|
+
if keys:
|
|
828
|
+
return _ssh_helpers.LocalHostKeyPolicy(keys, fallback=self._missing_host_key_policy)
|
|
829
|
+
return self._missing_host_key_policy
|
|
830
|
+
|
|
779
831
|
def __get_client(self) -> paramiko.SSHClient:
|
|
780
832
|
"""Connect using connection chain information.
|
|
781
833
|
|
|
@@ -785,12 +837,33 @@ class SSHClientBase(api.ExecHelper):
|
|
|
785
837
|
:raises RuntimeError: Unexpected state.
|
|
786
838
|
:raises ConnectionError: Cannot get SSH transport.
|
|
787
839
|
"""
|
|
840
|
+
opened: list[paramiko.SSHClient] = []
|
|
841
|
+
try:
|
|
842
|
+
return self.__connect_chain(opened)
|
|
843
|
+
except BaseException:
|
|
844
|
+
for client in reversed(opened): # paramiko leaves the transports of a failed connect running
|
|
845
|
+
client.close()
|
|
846
|
+
raise
|
|
847
|
+
|
|
848
|
+
def __connect_chain(self, opened: list[paramiko.SSHClient]) -> paramiko.SSHClient:
|
|
849
|
+
"""Connect the hosts of the connection chain one by one.
|
|
850
|
+
|
|
851
|
+
:param opened: Every client created here is added to the list at once, for the cleanup on failure.
|
|
852
|
+
:type opened: list[paramiko.SSHClient]
|
|
853
|
+
:return: Paramiko ssh connection object of the last host.
|
|
854
|
+
:rtype: paramiko.SSHClient
|
|
855
|
+
:raises ValueError: ProxyCommand found in a connection chain after the first host reached an.
|
|
856
|
+
:raises RuntimeError: Unexpected state.
|
|
857
|
+
:raises ConnectionError: Cannot get SSH transport.
|
|
858
|
+
"""
|
|
859
|
+
config, auth = self.__conn_chain[0]
|
|
788
860
|
|
|
789
861
|
last_ssh_client: paramiko.SSHClient = paramiko.SSHClient()
|
|
862
|
+
opened.append(last_ssh_client)
|
|
790
863
|
last_ssh_client.load_system_host_keys()
|
|
791
|
-
last_ssh_client.set_missing_host_key_policy(
|
|
792
|
-
|
|
793
|
-
|
|
864
|
+
last_ssh_client.set_missing_host_key_policy(
|
|
865
|
+
self._missing_host_key_policy if config.proxycommand else self.__host_key_policy(config.hostname)
|
|
866
|
+
)
|
|
794
867
|
|
|
795
868
|
auth.connect(
|
|
796
869
|
last_ssh_client,
|
|
@@ -798,10 +871,12 @@ class SSHClientBase(api.ExecHelper):
|
|
|
798
871
|
port=config.port or 22,
|
|
799
872
|
sock=paramiko.ProxyCommand(config.proxycommand) if config.proxycommand else None,
|
|
800
873
|
allow_ssh_agent=self.allow_ssh_agent,
|
|
874
|
+
timeout=self.__connect_timeout,
|
|
801
875
|
)
|
|
802
876
|
|
|
803
877
|
for config, auth in self.__conn_chain[1:]: # start has another logic, so do it out of cycle
|
|
804
878
|
ssh = paramiko.SSHClient()
|
|
879
|
+
opened.append(ssh)
|
|
805
880
|
ssh.load_system_host_keys()
|
|
806
881
|
ssh.set_missing_host_key_policy(self._missing_host_key_policy)
|
|
807
882
|
|
|
@@ -820,6 +895,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
820
895
|
port=config.port or 22,
|
|
821
896
|
sock=sock,
|
|
822
897
|
allow_ssh_agent=self.allow_ssh_agent,
|
|
898
|
+
timeout=self.__connect_timeout,
|
|
823
899
|
)
|
|
824
900
|
last_ssh_client = ssh
|
|
825
901
|
continue
|
|
@@ -831,10 +907,11 @@ class SSHClientBase(api.ExecHelper):
|
|
|
831
907
|
return last_ssh_client
|
|
832
908
|
|
|
833
909
|
def __connect_sftp(self) -> None:
|
|
834
|
-
"""SFTP
|
|
910
|
+
"""Open the SFTP channel, reconnecting the SSH session first if it was closed."""
|
|
835
911
|
with self.lock:
|
|
912
|
+
transport = self._ssh_transport # a failed reconnect is not "SFTP unavailable": let it propagate
|
|
836
913
|
try:
|
|
837
|
-
self.__sftp =
|
|
914
|
+
self.__sftp = transport.open_sftp_client()
|
|
838
915
|
except paramiko.SSHException:
|
|
839
916
|
self.logger.warning("SFTP enable failed! SSH only is accessible.")
|
|
840
917
|
|
|
@@ -844,6 +921,11 @@ class SSHClientBase(api.ExecHelper):
|
|
|
844
921
|
|
|
845
922
|
:rtype: paramiko.sftp_client.SFTPClient
|
|
846
923
|
:raises paramiko.SSHException: SFTP connection failed.
|
|
924
|
+
:raises ConnectionError: SSH session was closed and the reconnect failed.
|
|
925
|
+
:raises paramiko.SSHException: SSH session was closed and the reconnect failed (the original exception).
|
|
926
|
+
|
|
927
|
+
.. versionchanged:: 8.2.1 The SFTP channel is opened through the reconnecting transport getter,
|
|
928
|
+
so a closed SSH session is restored instead of failing with `AttributeError`.
|
|
847
929
|
"""
|
|
848
930
|
if self.__sftp is not None:
|
|
849
931
|
return self.__sftp
|
|
@@ -877,7 +959,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
877
959
|
"""
|
|
878
960
|
try:
|
|
879
961
|
self.__ssh.close()
|
|
880
|
-
except BaseException as e: # pragma: no cover # NOSONAR
|
|
962
|
+
except BaseException as e: # noqa: BLE001 # pragma: no cover # NOSONAR
|
|
881
963
|
with contextlib.suppress(AttributeError):
|
|
882
964
|
self.logger.debug(f"Exception in {self!s} destructor call: {e}")
|
|
883
965
|
self.__sftp = None
|
|
@@ -908,7 +990,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
908
990
|
|
|
909
991
|
@sudo_mode.setter
|
|
910
992
|
def sudo_mode(self, mode: bool) -> None:
|
|
911
|
-
"""
|
|
993
|
+
"""Change the persistent sudo mode of the connection object.
|
|
912
994
|
|
|
913
995
|
:param mode: Sudo status: enabled | disabled.
|
|
914
996
|
:type mode: bool
|
|
@@ -1061,7 +1143,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
1061
1143
|
result.read_stderr(src=async_result.stderr, log=self.logger if log_stderr else None, verbose=verbose)
|
|
1062
1144
|
|
|
1063
1145
|
def poll_pipes() -> None:
|
|
1064
|
-
"""
|
|
1146
|
+
"""Poll FIFO buffers."""
|
|
1065
1147
|
while not async_result.interface.status_event.is_set():
|
|
1066
1148
|
time.sleep(0.1)
|
|
1067
1149
|
if async_result.stdout or async_result.stderr:
|
|
@@ -1155,6 +1237,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
1155
1237
|
sudo_mode=self.sudo_mode,
|
|
1156
1238
|
auth=self.auth,
|
|
1157
1239
|
logger=self.logger,
|
|
1240
|
+
log_mask_re=self.log_mask_re,
|
|
1158
1241
|
**kwargs,
|
|
1159
1242
|
)
|
|
1160
1243
|
|
|
@@ -1564,6 +1647,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
1564
1647
|
sock=sock,
|
|
1565
1648
|
ssh_auth_map=ssh_auth_map if ssh_auth_map is not None else self.__auth_mapping,
|
|
1566
1649
|
keepalive=int(keepalive),
|
|
1650
|
+
connect_timeout=self.__connect_timeout,
|
|
1567
1651
|
)
|
|
1568
1652
|
|
|
1569
1653
|
def execute_through_host(
|
|
@@ -1787,7 +1871,7 @@ class SSHClientBase(api.ExecHelper):
|
|
|
1787
1871
|
results[remote.hostname, remote.port] = result
|
|
1788
1872
|
if result.exit_code not in prep_expected:
|
|
1789
1873
|
errors[remote.hostname, remote.port] = result
|
|
1790
|
-
except Exception as e:
|
|
1874
|
+
except Exception as e: # noqa: BLE001 # stored and reported to the caller
|
|
1791
1875
|
raised_exceptions[remote.hostname, remote.port] = e
|
|
1792
1876
|
|
|
1793
1877
|
if raised_exceptions: # always raise
|
|
@@ -3,16 +3,23 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import functools
|
|
6
|
+
import ipaddress
|
|
6
7
|
import pathlib
|
|
8
|
+
import socket
|
|
7
9
|
import typing
|
|
8
10
|
from collections.abc import Collection
|
|
11
|
+
from collections.abc import Iterable
|
|
9
12
|
|
|
10
13
|
import paramiko
|
|
14
|
+
import psutil
|
|
11
15
|
|
|
12
16
|
if typing.TYPE_CHECKING:
|
|
13
17
|
# noinspection PyPackageRequirements
|
|
14
18
|
import logwrap
|
|
15
19
|
|
|
20
|
+
LOCAL_HOST_KEYS_PATTERN = "ssh_host_*_key.pub"
|
|
21
|
+
LOCAL_HOST_KEYS_DIR = pathlib.Path("/etc/ssh")
|
|
22
|
+
|
|
16
23
|
SSHConfigDictLikeT = dict[str, str | int | bool | Collection[str]]
|
|
17
24
|
SSHConfigsDictT = dict[str, SSHConfigDictLikeT]
|
|
18
25
|
|
|
@@ -38,12 +45,100 @@ def _parse_ssh_config_file(file_path: pathlib.Path) -> paramiko.SSHConfig | None
|
|
|
38
45
|
config = paramiko.SSHConfig()
|
|
39
46
|
with file_path.open() as f_obj:
|
|
40
47
|
config.parse(f_obj)
|
|
41
|
-
except Exception:
|
|
48
|
+
except Exception: # noqa: BLE001 # any parse failure means no usable config
|
|
42
49
|
config = None
|
|
43
50
|
|
|
44
51
|
return config
|
|
45
52
|
|
|
46
53
|
|
|
54
|
+
def is_local_host(hostname: str) -> bool:
|
|
55
|
+
"""Check if the hostname points to the machine we are running on.
|
|
56
|
+
|
|
57
|
+
:param hostname: Host name or IP address from the connection parameters.
|
|
58
|
+
:type hostname: str
|
|
59
|
+
:return: Loopback address, `localhost`, own host name or an address of a local interface.
|
|
60
|
+
:rtype: bool
|
|
61
|
+
|
|
62
|
+
No name resolution is done: a name is local only if it is the own host name or its short form.
|
|
63
|
+
A link-local address without a scope id is ambiguous (any link has such addresses), so it is not local.
|
|
64
|
+
"""
|
|
65
|
+
name = hostname.strip("[]").lower()
|
|
66
|
+
try:
|
|
67
|
+
address = ipaddress.ip_address(name.partition("%")[0])
|
|
68
|
+
except ValueError:
|
|
69
|
+
own_name = socket.gethostname().lower()
|
|
70
|
+
return name in {"localhost", own_name, own_name.partition(".")[0]}
|
|
71
|
+
|
|
72
|
+
if address.is_loopback:
|
|
73
|
+
return True
|
|
74
|
+
if address.is_link_local and "%" not in name:
|
|
75
|
+
return False
|
|
76
|
+
try:
|
|
77
|
+
return any(
|
|
78
|
+
address == ipaddress.ip_address(addr.address.partition("%")[0])
|
|
79
|
+
for addrs in psutil.net_if_addrs().values()
|
|
80
|
+
for addr in addrs
|
|
81
|
+
if addr.family in {socket.AF_INET, socket.AF_INET6}
|
|
82
|
+
)
|
|
83
|
+
except (OSError, ValueError): # the answer only silences a warning: no verdict means "not local"
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def local_host_keys() -> tuple[paramiko.PKey, ...]:
|
|
88
|
+
"""Read the public host keys of the SSH server of this machine.
|
|
89
|
+
|
|
90
|
+
:return: Parsed keys, empty if there are none or they are not readable.
|
|
91
|
+
:rtype: tuple[paramiko.PKey, ...]
|
|
92
|
+
|
|
93
|
+
The files are read on every call: a key rotation must not leave stale keys, and the read costs less than
|
|
94
|
+
the connection itself.
|
|
95
|
+
"""
|
|
96
|
+
keys: list[paramiko.PKey] = []
|
|
97
|
+
for key_file in sorted(LOCAL_HOST_KEYS_DIR.glob(LOCAL_HOST_KEYS_PATTERN)):
|
|
98
|
+
try:
|
|
99
|
+
# known_hosts line format: "<names> <type> <base64 key>"
|
|
100
|
+
entry = paramiko.hostkeys.HostKeyEntry.from_line(f"local {key_file.read_text(encoding='ascii')}")
|
|
101
|
+
except (OSError, ValueError, paramiko.SSHException, paramiko.hostkeys.InvalidHostKey):
|
|
102
|
+
continue
|
|
103
|
+
if entry is not None and entry.key is not None:
|
|
104
|
+
keys.append(entry.key)
|
|
105
|
+
return tuple(keys)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class LocalHostKeyPolicy(paramiko.MissingHostKeyPolicy):
|
|
109
|
+
"""Missing host key policy for the local host: accept the host keys of this machine, ask the fallback otherwise.
|
|
110
|
+
|
|
111
|
+
:param keys: Public host keys of the SSH server of this machine.
|
|
112
|
+
:type keys: collections.abc.Iterable[paramiko.PKey]
|
|
113
|
+
:param fallback: Policy for any other key, so a server which is not this machine is treated as it was before.
|
|
114
|
+
:type fallback: paramiko.MissingHostKeyPolicy | type[paramiko.MissingHostKeyPolicy]
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
__slots__ = ("__fallback", "__keys")
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
keys: Iterable[paramiko.PKey],
|
|
122
|
+
fallback: paramiko.MissingHostKeyPolicy | type[paramiko.MissingHostKeyPolicy],
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Remember the keys and the fallback policy."""
|
|
125
|
+
self.__keys = frozenset(key.asbytes() for key in keys)
|
|
126
|
+
self.__fallback = fallback() if isinstance(fallback, type) else fallback
|
|
127
|
+
|
|
128
|
+
def missing_host_key(self, client: paramiko.SSHClient, hostname: str, key: paramiko.PKey) -> None:
|
|
129
|
+
"""Accept the key of this machine, ask the fallback policy about any other key.
|
|
130
|
+
|
|
131
|
+
:param client: SSH client (low level).
|
|
132
|
+
:type client: paramiko.SSHClient
|
|
133
|
+
:param hostname: Name of the host in `known_hosts` format.
|
|
134
|
+
:type hostname: str
|
|
135
|
+
:param key: Key offered by the server.
|
|
136
|
+
:type key: paramiko.PKey
|
|
137
|
+
"""
|
|
138
|
+
if key.asbytes() not in self.__keys:
|
|
139
|
+
self.__fallback.missing_host_key(client, hostname, key)
|
|
140
|
+
|
|
141
|
+
|
|
47
142
|
class SSHConfig:
|
|
48
143
|
"""Parsed SSH Config for creation connection."""
|
|
49
144
|
|
|
@@ -51,10 +51,10 @@ def kill_proc_tree(pid: int, including_parent: bool = True) -> None: # pragma:
|
|
|
51
51
|
|
|
52
52
|
try:
|
|
53
53
|
parent = psutil.Process(pid)
|
|
54
|
+
children: list[psutil.Process] = parent.children(recursive=True)
|
|
54
55
|
except psutil.NoSuchProcess:
|
|
55
56
|
# The process is already closed
|
|
56
57
|
return
|
|
57
|
-
children: list[psutil.Process] = parent.children(recursive=True)
|
|
58
58
|
child: psutil.Process
|
|
59
59
|
for child in children:
|
|
60
60
|
safe_stop(child) # SIGTERM to allow cleanup
|
|
@@ -66,7 +66,8 @@ def kill_proc_tree(pid: int, including_parent: bool = True) -> None: # pragma:
|
|
|
66
66
|
_, alive = psutil.wait_procs((parent,), timeout=1)
|
|
67
67
|
if alive:
|
|
68
68
|
safe_stop(parent, kill=True) # 2nd shot: SIGKILL
|
|
69
|
-
|
|
69
|
+
with contextlib.suppress(psutil.NoSuchProcess):
|
|
70
|
+
parent.wait(5)
|
|
70
71
|
|
|
71
72
|
|
|
72
73
|
# Subprocess extra arguments.
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '8.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (8, 2,
|
|
21
|
+
__version__ = version = '8.2.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (8, 2, 1)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gc72db6b6f'
|