Procházet zdrojové kódy

ParamikoSSHVendor: Add support for SSH config file

The ParamikoSSHVendor now reads SSH configuration from ~/.ssh/config
and uses host-specific settings when establishing connections. This
includes hostname aliases, user, port, and identity file settings.

Fixes #443
Jelmer Vernooij před 1 měsícem
rodič
revize
4562279e70
3 změnil soubory, kde provedl 92 přidání a 1 odebrání
  1. 5 0
      NEWS
  2. 41 1
      dulwich/contrib/paramiko_vendor.py
  3. 46 0
      tests/contrib/test_paramiko_vendor.py

+ 5 - 0
NEWS

@@ -38,6 +38,11 @@
    Previously used a hardcoded 7-character hash length.
    (Jelmer Vernooij, #824)
 
+ * ParamikoSSHVendor now reads SSH configuration from ~/.ssh/config.
+   Host settings including hostname, user, port, and identity file are
+   now respected when establishing SSH connections.
+   (Jelmer Vernooij, #443)
+
 0.23.1	2025-06-30
 
  * Support ``untracked_files="normal"`` argument to ``porcelain.status``,

+ 41 - 1
dulwich/contrib/paramiko_vendor.py

@@ -31,10 +31,13 @@ the dulwich.client.get_ssh_vendor attribute:
 This implementation is experimental and does not have any tests.
 """
 
+import os
+import warnings
 from typing import Any, BinaryIO, Optional, cast
 
 import paramiko
 import paramiko.client
+import paramiko.config
 
 
 class _ParamikoWrapper:
@@ -78,6 +81,22 @@ class ParamikoSSHVendor:
 
     def __init__(self, **kwargs: object) -> None:
         self.kwargs = kwargs
+        self.ssh_config = self._load_ssh_config()
+
+    def _load_ssh_config(self) -> paramiko.config.SSHConfig:
+        """Load SSH configuration from ~/.ssh/config."""
+        ssh_config = paramiko.config.SSHConfig()
+        config_path = os.path.expanduser("~/.ssh/config")
+        try:
+            with open(config_path) as config_file:
+                ssh_config.parse(config_file)
+        except FileNotFoundError:
+            # Config file doesn't exist - this is normal, ignore silently
+            pass
+        except (OSError, PermissionError) as e:
+            # Config file exists but can't be read - warn user
+            warnings.warn(f"Could not read SSH config file {config_path}: {e}")
+        return ssh_config
 
     def run_command(
         self,
@@ -93,18 +112,39 @@ class ParamikoSSHVendor:
     ) -> _ParamikoWrapper:
         client = paramiko.SSHClient()
 
-        connection_kwargs: dict[str, Any] = {"hostname": host}
+        # Get SSH config for this host
+        host_config = self.ssh_config.lookup(host)
+
+        connection_kwargs: dict[str, Any] = {
+            "hostname": host_config.get("hostname", host)
+        }
         connection_kwargs.update(self.kwargs)
+
+        # Use SSH config values if not explicitly provided
         if username:
             connection_kwargs["username"] = username
+        elif "user" in host_config:
+            connection_kwargs["username"] = host_config["user"]
+
         if port:
             connection_kwargs["port"] = port
+        elif "port" in host_config:
+            connection_kwargs["port"] = int(host_config["port"])
+
         if password:
             connection_kwargs["password"] = password
         if pkey:
             connection_kwargs["pkey"] = pkey
         if key_filename:
             connection_kwargs["key_filename"] = key_filename
+        elif "identityfile" in host_config:
+            # Use the first identity file from SSH config
+            identity_files = host_config["identityfile"]
+            if isinstance(identity_files, list) and identity_files:
+                connection_kwargs["key_filename"] = identity_files[0]
+            elif isinstance(identity_files, str):
+                connection_kwargs["key_filename"] = identity_files
+
         connection_kwargs.update(kwargs)
 
         policy = paramiko.client.MissingHostKeyPolicy()

+ 46 - 0
tests/contrib/test_paramiko_vendor.py

@@ -20,11 +20,14 @@
 
 """Tests for paramiko_vendor."""
 
+import os
 import socket
+import tempfile
 import threading
 from io import StringIO
 from typing import Optional
 from unittest import skipIf
+from unittest.mock import patch
 
 from .. import TestCase
 
@@ -221,3 +224,46 @@ class ParamikoSSHVendorTests(TestCase):
 
         # Fixme: it's return empty string
         # self.assertEqual(b'stderr\n', con.read_stderr(4096))
+
+    def test_ssh_config_parsing(self) -> None:
+        """Test that SSH config is properly parsed and used by ParamikoSSHVendor."""
+        # Create a temporary SSH config file
+        with tempfile.NamedTemporaryFile(mode="w", suffix=".config", delete=False) as f:
+            f.write(
+                f"""
+Host testserver
+    HostName 127.0.0.1
+    User testuser
+    Port {self.port}
+    IdentityFile /path/to/key
+"""
+            )
+            config_path = f.name
+
+        try:
+            # Mock the config path
+            with patch(
+                "dulwich.contrib.paramiko_vendor.os.path.expanduser"
+            ) as mock_expanduser:
+
+                def side_effect(path):
+                    if path == "~/.ssh/config":
+                        return config_path
+                    return path
+
+                mock_expanduser.side_effect = side_effect
+
+                vendor = ParamikoSSHVendor(
+                    allow_agent=False,
+                    look_for_keys=False,
+                )
+
+                # Test that SSH config values are loaded
+                host_config = vendor.ssh_config.lookup("testserver")
+                self.assertEqual(host_config["hostname"], "127.0.0.1")
+                self.assertEqual(host_config["user"], "testuser")
+                self.assertEqual(host_config["port"], str(self.port))
+                self.assertIn("/path/to/key", host_config["identityfile"])
+
+        finally:
+            os.unlink(config_path)