paramiko_vendor.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. # paramiko_vendor.py -- paramiko implementation of the SSHVendor interface
  2. # Copyright (C) 2013 Aaron O'Mullan <aaron.omullan@friendco.de>
  3. #
  4. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  5. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  6. # General Public License as published by the Free Software Foundation; version 2.0
  7. # or (at your option) any later version. You can redistribute it and/or
  8. # modify it under the terms of either of these two licenses.
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. # You should have received a copy of the licenses; if not, see
  17. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  18. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  19. # License, Version 2.0.
  20. #
  21. """Paramiko SSH support for Dulwich.
  22. To use this implementation as the SSH implementation in Dulwich, override
  23. the dulwich.client.get_ssh_vendor attribute:
  24. >>> from dulwich import client as _mod_client
  25. >>> from dulwich.contrib.paramiko_vendor import ParamikoSSHVendor
  26. >>> _mod_client.get_ssh_vendor = ParamikoSSHVendor
  27. This implementation has comprehensive tests in tests/contrib/test_paramiko_vendor.py.
  28. """
  29. import os
  30. import warnings
  31. from typing import Any, BinaryIO, Optional, cast
  32. import paramiko
  33. import paramiko.client
  34. import paramiko.config
  35. class _ParamikoWrapper:
  36. """Wrapper for paramiko SSH channel to provide a file-like interface."""
  37. def __init__(self, client: paramiko.SSHClient, channel: paramiko.Channel) -> None:
  38. """Initialize the paramiko wrapper.
  39. Args:
  40. client: The SSH client instance
  41. channel: The SSH channel for communication
  42. """
  43. self.client = client
  44. self.channel = channel
  45. # Channel must block
  46. self.channel.setblocking(True)
  47. @property
  48. def stderr(self) -> BinaryIO:
  49. """Get stderr stream from the channel.
  50. Returns:
  51. Binary IO stream for stderr
  52. """
  53. return cast(BinaryIO, self.channel.makefile_stderr("rb"))
  54. def can_read(self) -> bool:
  55. """Check if data is available to read.
  56. Returns:
  57. True if data is available
  58. """
  59. return self.channel.recv_ready()
  60. def write(self, data: bytes) -> None:
  61. """Write data to the channel.
  62. Args:
  63. data: Bytes to write
  64. """
  65. return self.channel.sendall(data)
  66. def read(self, n: Optional[int] = None) -> bytes:
  67. """Read data from the channel.
  68. Args:
  69. n: Number of bytes to read (default: 4096)
  70. Returns:
  71. Bytes read from the channel
  72. """
  73. data = self.channel.recv(n or 4096)
  74. data_len = len(data)
  75. # Closed socket
  76. if not data:
  77. return b""
  78. # Read more if needed
  79. if n and data_len < n:
  80. diff_len = n - data_len
  81. return data + self.read(diff_len)
  82. return data
  83. def close(self) -> None:
  84. """Close the SSH channel."""
  85. self.channel.close()
  86. class ParamikoSSHVendor:
  87. """SSH vendor implementation using paramiko."""
  88. # http://docs.paramiko.org/en/2.4/api/client.html
  89. def __init__(self, **kwargs: object) -> None:
  90. """Initialize the paramiko SSH vendor.
  91. Args:
  92. **kwargs: Additional keyword arguments passed to SSHClient
  93. """
  94. self.kwargs = kwargs
  95. self.ssh_config = self._load_ssh_config()
  96. def _load_ssh_config(self) -> paramiko.config.SSHConfig:
  97. """Load SSH configuration from ~/.ssh/config."""
  98. ssh_config = paramiko.config.SSHConfig()
  99. config_path = os.path.expanduser("~/.ssh/config")
  100. try:
  101. with open(config_path) as config_file:
  102. ssh_config.parse(config_file)
  103. except FileNotFoundError:
  104. # Config file doesn't exist - this is normal, ignore silently
  105. pass
  106. except (OSError, PermissionError) as e:
  107. # Config file exists but can't be read - warn user
  108. warnings.warn(f"Could not read SSH config file {config_path}: {e}")
  109. return ssh_config
  110. def run_command(
  111. self,
  112. host: str,
  113. command: str,
  114. username: Optional[str] = None,
  115. port: Optional[int] = None,
  116. password: Optional[str] = None,
  117. pkey: Optional[paramiko.PKey] = None,
  118. key_filename: Optional[str] = None,
  119. protocol_version: Optional[int] = None,
  120. **kwargs: object,
  121. ) -> _ParamikoWrapper:
  122. """Run a command on a remote host via SSH.
  123. Args:
  124. host: Hostname to connect to
  125. command: Command to execute
  126. username: SSH username (optional)
  127. port: SSH port (optional)
  128. password: SSH password (optional)
  129. pkey: Private key for authentication (optional)
  130. key_filename: Path to private key file (optional)
  131. protocol_version: SSH protocol version (optional)
  132. **kwargs: Additional keyword arguments
  133. Returns:
  134. _ParamikoWrapper instance for the SSH channel
  135. """
  136. client = paramiko.SSHClient()
  137. # Get SSH config for this host
  138. host_config = self.ssh_config.lookup(host)
  139. connection_kwargs: dict[str, Any] = {
  140. "hostname": host_config.get("hostname", host)
  141. }
  142. connection_kwargs.update(self.kwargs)
  143. # Use SSH config values if not explicitly provided
  144. if username:
  145. connection_kwargs["username"] = username
  146. elif "user" in host_config:
  147. connection_kwargs["username"] = host_config["user"]
  148. if port:
  149. connection_kwargs["port"] = port
  150. elif "port" in host_config:
  151. connection_kwargs["port"] = int(host_config["port"])
  152. if password:
  153. connection_kwargs["password"] = password
  154. if pkey:
  155. connection_kwargs["pkey"] = pkey
  156. if key_filename:
  157. connection_kwargs["key_filename"] = key_filename
  158. elif "identityfile" in host_config:
  159. # Use the first identity file from SSH config
  160. identity_files = host_config["identityfile"]
  161. if isinstance(identity_files, list) and identity_files:
  162. connection_kwargs["key_filename"] = identity_files[0]
  163. elif isinstance(identity_files, str):
  164. connection_kwargs["key_filename"] = identity_files
  165. connection_kwargs.update(kwargs)
  166. policy = paramiko.client.MissingHostKeyPolicy()
  167. client.set_missing_host_key_policy(policy)
  168. client.connect(**connection_kwargs)
  169. # Open SSH session
  170. transport = client.get_transport()
  171. if transport is None:
  172. raise RuntimeError("Transport is None")
  173. channel = transport.open_session()
  174. if protocol_version is None or protocol_version == 2:
  175. channel.set_environment_variable(name="GIT_PROTOCOL", value="version=2")
  176. # Run commands
  177. channel.exec_command(command)
  178. return _ParamikoWrapper(client, channel)