exec-helpers 8.2.0__tar.gz → 8.2.2__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.
Files changed (43) hide show
  1. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/.pre-commit-config.yaml +2 -2
  2. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/PKG-INFO +6 -2
  3. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/README.rst +5 -1
  4. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/__init__.py +2 -3
  5. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/_helpers.py +40 -0
  6. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/_ssh_base.py +136 -28
  7. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/_ssh_helpers.py +96 -1
  8. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/_subprocess_helpers.py +3 -2
  9. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/_version.py +3 -3
  10. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/api.py +31 -8
  11. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/async_api/api.py +30 -6
  12. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/async_api/exec_result.py +18 -6
  13. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/async_api/subprocess.py +106 -28
  14. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/exec_result.py +18 -18
  15. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/ssh.py +3 -0
  16. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/ssh_auth.py +42 -29
  17. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/subprocess.py +81 -18
  18. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers.egg-info/PKG-INFO +6 -2
  19. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers.egg-info/SOURCES.txt +2 -0
  20. exec_helpers-8.2.2/exec_helpers.egg-info/scm_file_list.json +77 -0
  21. exec_helpers-8.2.2/exec_helpers.egg-info/scm_version.json +8 -0
  22. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/pyproject.toml +116 -97
  23. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/.editorconfig +0 -0
  24. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/.readthedocs.yaml +0 -0
  25. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/CI_REQUIREMENTS.txt +0 -0
  26. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/LICENSE +0 -0
  27. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/MANIFEST.in +0 -0
  28. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/build_requirements.txt +0 -0
  29. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/classifiers.txt +0 -0
  30. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/__init__.pyi +0 -0
  31. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/_log_templates.py +0 -0
  32. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/async_api/__init__.py +0 -0
  33. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/async_api/__init__.pyi +0 -0
  34. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/constants.py +0 -0
  35. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/exceptions.py +0 -0
  36. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/proc_enums.py +0 -0
  37. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers/py.typed +0 -0
  38. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers.egg-info/dependency_links.txt +0 -0
  39. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers.egg-info/not-zip-safe +0 -0
  40. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers.egg-info/requires.txt +0 -0
  41. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/exec_helpers.egg-info/top_level.txt +0 -0
  42. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/requirements.txt +0 -0
  43. {exec_helpers-8.2.0 → exec_helpers-8.2.2}/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.15.13
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.21.2"
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.0
3
+ Version: 8.2.2
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:: `shell=true` is always set.
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:: `shell=true` is always set.
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
- try: # noqa: SIM105,S314,RUF100
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, contextlib.AbstractContextManager[SshExecuteAsyncResult]):
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.command!r}, "
226
- f"stdin={self.stdin!r}, "
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 = datetime.datetime.now(tz=datetime.UTC)
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
- return SshExecuteAsyncResult(
337
- interface=chan,
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
- """Main SSH Client helper."""
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 __connect(self) -> None:
759
- """Main method for connection open."""
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(self._missing_host_key_policy)
792
-
793
- config, auth = self.__conn_chain[0]
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 connection opener."""
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 = self.__ssh.open_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
- """Persistent sudo mode change for connection object.
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
- """Polling task for FIFO buffers."""
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
@@ -1821,9 +1905,15 @@ class SSHClientBase(api.ExecHelper):
1821
1905
  :type path: str | pathlib.PurePath
1822
1906
  :return: path is valid (an object exists).
1823
1907
  :rtype: bool
1908
+ :raises ConnectionError: SSH session was closed and the reconnect failed.
1909
+ :raises OSError: SSH session was closed and the reconnect failed with a socket error.
1910
+ :raises paramiko.SSHException: SFTP connection failed, or the reconnect failed with a paramiko error.
1911
+
1912
+ .. versionchanged:: 8.2.2 A failed reconnect is raised instead of being reported as a missing path.
1824
1913
  """
1914
+ sftp = self._sftp # a failed reconnect is raised, not reported as a missing path
1825
1915
  try:
1826
- self._sftp.lstat(pathlib.PurePath(path).as_posix())
1916
+ sftp.lstat(pathlib.PurePath(path).as_posix())
1827
1917
  except OSError:
1828
1918
  return False
1829
1919
 
@@ -1858,9 +1948,15 @@ class SSHClientBase(api.ExecHelper):
1858
1948
  :type path: str | pathlib.PurePath
1859
1949
  :return: path is file.
1860
1950
  :rtype: bool
1951
+ :raises ConnectionError: SSH session was closed and the reconnect failed.
1952
+ :raises OSError: SSH session was closed and the reconnect failed with a socket error.
1953
+ :raises paramiko.SSHException: SFTP connection failed, or the reconnect failed with a paramiko error.
1954
+
1955
+ .. versionchanged:: 8.2.2 A failed reconnect is raised instead of being reported as a missing path.
1861
1956
  """
1957
+ sftp = self._sftp # a failed reconnect is raised, not reported as a missing path
1862
1958
  try:
1863
- attrs: paramiko.sftp_attr.SFTPAttributes = self._sftp.lstat(pathlib.PurePath(path).as_posix())
1959
+ attrs: paramiko.sftp_attr.SFTPAttributes = sftp.lstat(pathlib.PurePath(path).as_posix())
1864
1960
  if attrs.st_mode is None:
1865
1961
  return False
1866
1962
  return stat.S_ISREG(attrs.st_mode)
@@ -1874,9 +1970,15 @@ class SSHClientBase(api.ExecHelper):
1874
1970
  :type path: str | pathlib.PurePath
1875
1971
  :return: path is directory.
1876
1972
  :rtype: bool
1973
+ :raises ConnectionError: SSH session was closed and the reconnect failed.
1974
+ :raises OSError: SSH session was closed and the reconnect failed with a socket error.
1975
+ :raises paramiko.SSHException: SFTP connection failed, or the reconnect failed with a paramiko error.
1976
+
1977
+ .. versionchanged:: 8.2.2 A failed reconnect is raised instead of being reported as a missing path.
1877
1978
  """
1979
+ sftp = self._sftp # a failed reconnect is raised, not reported as a missing path
1878
1980
  try:
1879
- attrs: paramiko.sftp_attr.SFTPAttributes = self._sftp.lstat(pathlib.PurePath(path).as_posix())
1981
+ attrs: paramiko.sftp_attr.SFTPAttributes = sftp.lstat(pathlib.PurePath(path).as_posix())
1880
1982
  if attrs.st_mode is None:
1881
1983
  return False
1882
1984
  return stat.S_ISDIR(attrs.st_mode)
@@ -1890,9 +1992,15 @@ class SSHClientBase(api.ExecHelper):
1890
1992
  :type path: str | pathlib.PurePath
1891
1993
  :return: path is symlink.
1892
1994
  :rtype: bool
1995
+ :raises ConnectionError: SSH session was closed and the reconnect failed.
1996
+ :raises OSError: SSH session was closed and the reconnect failed with a socket error.
1997
+ :raises paramiko.SSHException: SFTP connection failed, or the reconnect failed with a paramiko error.
1998
+
1999
+ .. versionchanged:: 8.2.2 A failed reconnect is raised instead of being reported as a missing path.
1893
2000
  """
2001
+ sftp = self._sftp # a failed reconnect is raised, not reported as a missing path
1894
2002
  try:
1895
- attrs: paramiko.sftp_attr.SFTPAttributes = self._sftp.lstat(pathlib.PurePath(path).as_posix())
2003
+ attrs: paramiko.sftp_attr.SFTPAttributes = sftp.lstat(pathlib.PurePath(path).as_posix())
1896
2004
  if attrs.st_mode is None:
1897
2005
  return False
1898
2006
  return stat.S_ISLNK(attrs.st_mode)
@@ -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
- parent.wait(5)
69
+ with contextlib.suppress(psutil.NoSuchProcess):
70
+ parent.wait(5)
70
71
 
71
72
 
72
73
  # Subprocess extra arguments.